deploy1 6 years ago
commit
a658a65c7e
100 changed files with 34613 additions and 0 deletions
  1. 14 0
      .dockerignore
  2. 6 0
      .gitignore
  3. 29 0
      Dockerfile
  4. 13 0
      Gulpfile.js
  5. 661 0
      LICENSE
  6. 1 0
      README.md
  7. 33 0
      app.js
  8. 5 0
      bin/www
  9. 33 0
      config/default.json
  10. 17 0
      electron-windows.md
  11. 563 0
      helpers/artifact_converter.js
  12. 128 0
      helpers/importer.js
  13. 93 0
      helpers/mailer.js
  14. 70 0
      helpers/phantom.js
  15. 161 0
      helpers/redis.js
  16. 149 0
      helpers/space-render.js
  17. 84 0
      helpers/uploader.js
  18. 307 0
      helpers/websockets.js
  19. 321 0
      locales/de.js
  20. 324 0
      locales/en.js
  21. 318 0
      locales/fr.js
  22. 19 0
      middlewares/404.js
  23. 10 0
      middlewares/500.js
  24. 57 0
      middlewares/api_helpers.js
  25. 20 0
      middlewares/artifact_helpers.js
  26. 48 0
      middlewares/cors.js
  27. 16 0
      middlewares/error_helpers.js
  28. 17 0
      middlewares/i18n.js
  29. 47 0
      middlewares/session.js
  30. 118 0
      middlewares/space_helpers.js
  31. 34 0
      models/action.js
  32. 364 0
      models/db.js
  33. 80 0
      models/migrations/01-spaces-delete-cascade.js
  34. 60 0
      package.json
  35. BIN
      public/fonts/OpenSans-Regular.ttf
  36. 10 0
      public/fonts/font.scss
  37. BIN
      public/fonts/icon-regular-webfont.eot
  38. 99 0
      public/fonts/icon-regular-webfont.svg
  39. BIN
      public/fonts/icon-regular-webfont.ttf
  40. BIN
      public/fonts/icon-regular-webfont.woff
  41. 2648 0
      public/fonts/unicode.scss
  42. BIN
      public/images/border-dotted.png
  43. BIN
      public/images/cursors/crosshair.png
  44. BIN
      public/images/cursors/crosshair.psd
  45. BIN
      public/images/cursors/eyedrop.png
  46. BIN
      public/images/cursors/eyedropper.png
  47. BIN
      public/images/cursors/eyedropper.psd
  48. BIN
      public/images/cursors/pencil.png
  49. BIN
      public/images/cursors/pencil.psd
  50. BIN
      public/images/cursors/text-frame.png
  51. BIN
      public/images/cursors/text-frame.psd
  52. 11 0
      public/images/diamond.svg
  53. BIN
      public/images/favicon.png
  54. BIN
      public/images/hourglass.gif
  55. BIN
      public/images/hue.png
  56. BIN
      public/images/huevalue.png
  57. 80 0
      public/images/ideate-char 2.svg
  58. 85 0
      public/images/offline-char.svg
  59. BIN
      public/images/opacity-grid.png
  60. BIN
      public/images/opacity-strip.png
  61. BIN
      public/images/placeholder-4x3.gif
  62. BIN
      public/images/sd5-hero2-compressed.jpg
  63. BIN
      public/images/sd5-keyvisual-compressed.jpg
  64. 124 0
      public/images/sd5-logo-inverted.svg
  65. BIN
      public/images/sd5-logo.png
  66. 118 0
      public/images/sd5-logo.svg
  67. 50 0
      public/images/spacedeck-logo.svg
  68. BIN
      public/images/spinner.gif
  69. BIN
      public/images/spinner2.gif
  70. 7 0
      public/images/triangle.svg
  71. 339 0
      public/javascripts/backend.js
  72. 745 0
      public/javascripts/clipboard.js
  73. 841 0
      public/javascripts/fastclick.js
  74. 249 0
      public/javascripts/helper.js
  75. 2332 0
      public/javascripts/i18next-1.11.2.js
  76. 1 0
      public/javascripts/jquery-2.1.4.min.js
  77. 133 0
      public/javascripts/link_parser.js
  78. 946 0
      public/javascripts/locales.js
  79. 7157 0
      public/javascripts/lodash.compat.js
  80. 1861 0
      public/javascripts/medium.patched.js
  81. 2610 0
      public/javascripts/moment.js
  82. 951 0
      public/javascripts/mousetrap.js
  83. 147 0
      public/javascripts/packer.growing.js
  84. 627 0
      public/javascripts/route-recognizer.js
  85. 517 0
      public/javascripts/smoke.js
  86. 127 0
      public/javascripts/spacedeck_account.js
  87. 81 0
      public/javascripts/spacedeck_avatars.js
  88. 665 0
      public/javascripts/spacedeck_board_artifacts.js
  89. 568 0
      public/javascripts/spacedeck_directives.js
  90. 20 0
      public/javascripts/spacedeck_formatting.js
  91. 101 0
      public/javascripts/spacedeck_modals.js
  92. 294 0
      public/javascripts/spacedeck_routes.js
  93. 3015 0
      public/javascripts/spacedeck_sections.js
  94. 960 0
      public/javascripts/spacedeck_spaces.js
  95. 127 0
      public/javascripts/spacedeck_teams.js
  96. 35 0
      public/javascripts/spacedeck_updates.js
  97. 299 0
      public/javascripts/spacedeck_users.js
  98. 167 0
      public/javascripts/spacedeck_vue.js
  99. 267 0
      public/javascripts/spacedeck_websockets.js
  100. 979 0
      public/javascripts/spacedeck_whiteboard.js

+ 14 - 0
.dockerignore

@@ -0,0 +1,14 @@
+.DS_Store
+.git
+logs
+*.log
+scripts
+pids
+*.pid
+*.seed
+lib-cov
+coverage
+.grunt
+.lock-wscript
+build/Release
+node_modules

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+node_modules
+javascripts/maps
+javascripts/spacedeck.js
+*.swp
+*~
+

+ 29 - 0
Dockerfile

@@ -0,0 +1,29 @@
+FROM spacedeck/docker-baseimage:latest
+ENV NODE_ENV production
+
+RUN mkdir -p /usr/src/app
+WORKDIR /usr/src/app
+
+COPY package.json /usr/src/app/
+RUN npm install
+RUN npm install gulp-rev-replace gulp-clean gulp-fingerprint gulp-rev gulp-rev-all gulp-rev-replace
+RUN npm install -g --save-dev gulp
+
+COPY app.js Dockerfile Gulpfile.js LICENSE /usr/src/app/
+COPY config /usr/src/app/config
+COPY helpers /usr/src/app/helpers
+COPY locales /usr/src/app/locales
+COPY middlewares /usr/src/app/middlewares
+COPY models /usr/src/app/models
+COPY public /usr/src/app/public
+COPY routes /usr/src/app/routes
+COPY styles /usr/src/app/styles
+COPY views /usr/src/app/views
+
+RUN gulp all
+RUN npm cache clean
+
+CMD [ "node", "app.js" ]
+
+EXPOSE 9666
+

+ 13 - 0
Gulpfile.js

@@ -0,0 +1,13 @@
+var gulp = require('gulp');
+var sass = require('gulp-sass');
+var concat = require('gulp-concat');
+
+gulp.task('styles', function() {
+  gulp.src('styles/**/*.scss')
+    .pipe(sass({
+        errLogToConsole: true
+    }))
+    .pipe(gulp.dest('./public/stylesheets/'))
+    .pipe(concat('style.css'));
+});
+

+ 661 - 0
LICENSE

@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<http://www.gnu.org/licenses/>.

+ 1 - 0
README.md

@@ -0,0 +1 @@
+##spacedeck-open

+ 33 - 0
app.js

@@ -0,0 +1,33 @@
+const spacedeck = require('./spacedeck')
+
+const electron = require('electron')
+const electronApp = electron.app
+const BrowserWindow = electron.BrowserWindow
+let mainWindow
+
+function createWindow () {
+  mainWindow = new BrowserWindow({width: 1200, height: 700})
+  mainWindow.loadURL("http://localhost:9666")
+  mainWindow.on('closed', function () {
+    mainWindow = null
+  })
+}
+
+electronApp.on('ready', createWindow)
+
+// Quit when all windows are closed.
+electronApp.on('window-all-closed', function () {
+  // On OS X it is common for applications and their menu bar
+  // to stay active until the user quits explicitly with Cmd + Q
+  if (process.platform !== 'darwin') {
+    electronApp.quit()
+  }
+})
+
+electronApp.on('activate', function () {
+  // On OS X it's common to re-create a window in the app when the
+  // dock icon is clicked and there are no other windows open.
+  if (mainWindow === null) {
+    createWindow()
+  }
+})

+ 5 - 0
bin/www

@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+
+var app = require('../app');
+var http = require('http');
+var server = http.createServer(app);

+ 33 - 0
config/default.json

@@ -0,0 +1,33 @@
+{
+  "team_name": "My Open Spacedeck",
+  "contact_email": "support@example.org",
+
+  "endpoint": "http://localhost:9666",
+
+  "storage_region": "eu-central-1",
+  //"storage_bucket": "sdeck-development",
+  //"storage_cdn": "http://localhost:9123/sdeck-development",
+  //"storage_endpoint": "http://storage:9000",
+
+  "storage_bucket": "my_spacedeck_bucket",
+  "storage_cdn": "/storage",
+  "storage_local_path": "./storage",
+
+  "redis_mock": true,
+  "mongodb_host": "localhost",
+  "redis_host": "localhost",
+
+  "google_access" : "",
+  "google_secret" : "",
+  "admin_pass": "very_secret_admin_password",
+  "phantom_api_secret": "very_secret_phantom_password",
+
+  // Choose "console" or "smtp"
+  "mail_provider": "smtp",
+  "mail_smtp_host": "your.smtp.host",
+  "mail_smtp_port": 465,
+  "mail_smtp_secure": true,
+  "mail_smtp_require_tls": true,
+  "mail_smtp_user": "your.smtp.user",
+  "mail_smtp_pass": "your.secret.smtp.password"
+}

+ 17 - 0
electron-windows.md

@@ -0,0 +1,17 @@
+
+# Windows Electron Build
+
+sqlite3 needs to be manually built for the iojs version that electron ships. The following code assumes electron v1.8.4.
+
+````
+npm -g install windows-build-tools
+
+cd node_modules\sqlite3
+
+node-gyp configure --module_name=node_sqlite3 --module_path=../lib/binding/electron-v1.8-win32-x64
+
+node-gyp rebuild --target=1.8.4 --target_platform=win32 --dist-url=https://atom.io/download/atom-shell --module_name=node_sqlite3 --module_path=../lib/binding/electron-v1.8-win32-x64 --msvs_version=2015
+
+cd ..\..
+````
+

+ 563 - 0
helpers/artifact_converter.js

@@ -0,0 +1,563 @@
+'use strict';
+
+const exec = require('child_process');
+const gm = require('gm');
+const async = require('async');
+const fs = require('fs');
+const Models = require('../models/db');
+const uploader = require('../helpers/uploader');
+const path = require('path');
+const os = require('os');
+
+const db = require('../models/db');
+const Sequelize = require('sequelize');
+const Op = Sequelize.Op;
+
+const mime = require('mime-types');
+const fileType = require('file-type');
+const readChunk = require('read-chunk');
+
+const convertableImageTypes = [
+  "image/png",
+  "image/jpeg",
+  "application/pdf",
+  "image/jpg",
+  "image/gif",
+  "image/tiff",
+  "image/vnd.adobe.photoshop"];
+
+const convertableVideoTypes = [
+  "video/quicktime",
+  "video/3gpp",
+  "video/mpeg",
+  "video/mp4",
+  "video/ogg"];
+
+const convertableAudioTypes = [
+  "application/ogg",
+  "audio/amr",
+  "audio/3ga",
+  "audio/wave",
+  "audio/3gpp",
+  "audio/x-wav",
+  "audio/aiff",
+  "audio/x-aiff",
+  "audio/ogg",
+  "audio/mp4",
+  "audio/x-m4a",
+  "audio/mpeg",
+  "audio/mp3",
+  "audio/x-hx-aac-adts",
+  "audio/aac"];
+
+
+function getDuration(localFilePath, callback){
+  exec.execFile("ffprobe", ["-show_format", "-of", "json", localFilePath], function(error, stdout, stderr) {
+    var test = JSON.parse(stdout);
+    callback(parseFloat(test.format.duration));
+  });
+}
+
+function createWaveform(fileName, localFilePath, callback){
+  var filePathImage = localFilePath + "-" + (new Date().getTime()) + ".png";
+
+  getDuration(localFilePath, function(duration){
+    var totalTime = duration || 1.0;
+    var pixelsPerSecond = 256.0;
+    do {
+      var targetWidth = parseInt(pixelsPerSecond*totalTime, 10);
+      if (targetWidth>2048) pixelsPerSecond/=2.0;
+    } while (targetWidth>2048 && pixelsPerSecond>1);
+
+    exec.execFile("audiowaveform",
+      [
+        "-w",
+        ""+targetWidth,
+        "--pixels-per-second",
+        ""+parseInt(pixelsPerSecond),
+        "--background-color", "ffffff00",
+        "--border-color", "ffffff",
+        "--waveform-color", "3498db",
+        "--no-axis-labels",
+        "-i", localFilePath, "-o", filePathImage
+      ],
+    {}, function(error, stdout, stderr) {
+      if(!error) {
+        callback(null, filePathImage);
+      } else {
+        console.log("error:", stdout, stderr);
+        callback(error, null);
+      }
+    });
+  });
+}
+
+function convertVideo(fileName, filePath, codec, callback, progressCallback) {
+  var ext = path.extname(fileName);
+  var presetMime = mime.lookup(fileName);
+
+  var newExt = codec == "mp4" ? "mp4" : "ogv";
+  var convertedPath = filePath + "." + newExt;
+
+  console.log("converting", filePath, "to", convertedPath);
+
+  var convertArgs = (codec == "mp4") ? [
+    "-i", filePath,
+    "-threads", "4",
+    "-vf", "scale=1280:trunc(ow/a/2)*2", // scale to width of 1280, truncating height to an even value
+    "-b:v", "2000k",
+    "-acodec", "libvo_aacenc",
+    "-b:a", "96k",
+    "-vcodec", "libx264",
+    "-y", convertedPath ]
+  : [
+    "-i", filePath,
+    "-threads", "4",
+    "-vf", "scale=1280:trunc(ow/a/2)*2", // scale to width of 1280, truncating height to an even value
+    "-b:v", "2000k",
+    "-acodec", "libvorbis",
+    "-b:a", "96k",
+    "-vcodec", "libtheora",
+    "-y", convertedPath];
+
+  var ff = exec.spawn('ffmpeg', convertArgs, {
+    stdio: [
+      'pipe', // use parents stdin for child
+      'pipe', // pipe child's stdout to parent
+      'pipe'
+    ]
+  });
+
+  ff.stdout.on('data', function (data) {
+    console.log('[ffmpeg-video] stdout: ' + data);
+  });
+
+  ff.stderr.on('data', function (data) {
+    console.log('[ffmpeg-video] stderr: ' + data);
+    if (progressCallback) {
+      progressCallback(data);
+    }
+  });
+
+  ff.on('close', function (code) {
+    console.log('[ffmpeg-video] child process exited with code ' + code);
+    if (!code) { 
+      console.log("converted", filePath, "to", convertedPath);
+      callback(null, convertedPath);
+    } else {
+      callback(code, null);
+    }
+  });
+}
+
+function convertAudio(fileName, filePath, codec, callback) {
+  var ext = path.extname(fileName);
+  var presetMime = mime.lookup(fileName);
+
+  var newExt = codec == "mp3" ? "mp3" : "ogg";
+  var convertedPath = filePath + "." + newExt;
+
+  console.log("converting audio", filePath, "to", convertedPath);
+
+  var convertArgs = (ext == ".aac") ? [ "-i", filePath, "-y", convertedPath ]
+  : [ "-i", filePath,
+    "-b:a", "128k",
+    "-y", convertedPath];
+
+  exec.execFile("ffmpeg", convertArgs , {}, function(error, stdout, stderr) {
+    if(!error){
+      console.log("converted", filePath, "to", convertedPath);
+      callback(null, convertedPath);
+    }else{
+      console.log(error,stdout, stderr);
+      callback(error, null);
+    }
+  });
+}
+
+function createThumbnailForVideo(fileName, filePath, callback) {
+  var filePathImage = filePath + ".jpg";
+  exec.execFile("ffmpeg", ["-y", "-i", filePath, "-ss", "00:00:01.00", "-vcodec", "mjpeg", "-vframes", "1", "-f", "image2", filePathImage], {}, function(error, stdout, stderr){
+    if(!error){
+      callback(null, filePathImage);
+    }else{
+      console.log("error:", stdout, stderr);
+      callback(error, null);
+    }
+  });
+}
+
+function getMime(fileName, filePath, callback) {
+  var ext = path.extname(fileName);
+  var presetMime = mime.lookup(fileName);
+  
+  if (presetMime) {
+    callback(null, presetMime);
+  } else {
+    const buffer = readChunk.sync(filePath, 0, 4100);
+    var mimeType = fileType(buffer);
+    callback(null, mimeType);
+  }
+}
+
+function resizeAndUpload(a, size, max, fileName, localFilePath, callback) {
+  if (max>320 || size.width > max || size.height > max) {
+    var resizedFileName = max + "_"+fileName;
+    var s3Key = "s"+ a.space_id.toString() + "/a" + a._id.toString() + "/" + resizedFileName;
+    var localResizedFilePath = os.tmpdir()+"/"+resizedFileName;
+    gm(localFilePath).resize(max, max).autoOrient().write(localResizedFilePath, function (err) {
+      if(!err) {
+        uploader.uploadFile(s3Key, "image/jpeg", localResizedFilePath, function(err, url) {
+          if (err) callback(err);
+          else{
+            fs.unlink(localResizedFilePath, function (err) {
+              if (err) {
+                console.error(err);
+                callback(null, url);
+              }
+              else callback(null, url);
+            });
+          }
+        });
+      } else {
+        console.error(err);
+        callback(err);
+      }
+    });
+  } else {
+    callback(null, "");
+  }
+}
+
+
+var resizeAndUploadImage = function(a, mimeType, size, fileName, fileNameOrg, imageFilePath, originalFilePath, payloadCallback) {
+  async.parallel({
+    small: function(callback){
+      resizeAndUpload(a, size, 320, fileName, imageFilePath, callback);
+    },
+    medium: function(callback){
+      resizeAndUpload(a, size, 800, fileName, imageFilePath, callback);
+    },
+    big: function(callback){
+      resizeAndUpload(a, size, 1920, fileName, imageFilePath, callback);
+    },
+    original: function(callback){
+      var s3Key = "s"+ a.space_id.toString() + "/a" + a._id + "/" + fileNameOrg;
+      uploader.uploadFile(s3Key, mimeType, originalFilePath, function(err, url){
+        callback(null, url);
+      });
+    }
+  }, function(err, results) {
+    a.state = "idle";
+    a.mime = mimeType;
+    var stats = fs.statSync(originalFilePath);
+
+    a.payload_size = stats["size"];
+    a.payload_thumbnail_web_uri = results.small;
+    a.payload_thumbnail_medium_uri = results.medium;
+    a.payload_thumbnail_big_uri = results.big;
+    a.payload_uri = results.original;
+
+    var factor = 320/size.width;
+    a.w = Math.round(size.width*factor);
+    a.h = Math.round(size.height*factor);
+
+    a.updated_at = new Date();
+    db.packArtifact(a);
+
+    a.save().then(function() {
+      fs.unlink(originalFilePath, function (err) {
+        if (err){
+          console.error(err);
+          payloadCallback(err, null);
+        } else {
+          console.log('successfully deleted ' + originalFilePath);
+          payloadCallback(null, a);
+        }
+      });
+    });
+  });
+};
+
+module.exports = {
+  convert: function(a, fileName, localFilePath, payloadCallback, progressCallback) {
+    getMime(fileName, localFilePath, function(err, mimeType){
+      console.log("[convert] fn: "+fileName+" local: "+localFilePath+" mimeType:", mimeType);
+
+      if (!err) {
+        if (convertableImageTypes.indexOf(mimeType) > -1) {
+         
+          gm(localFilePath).size(function (err, size) {
+            console.log("[convert] gm:", err, size);
+
+            if (!err) {
+              if(mimeType == "application/pdf") {
+                var firstImagePath =  localFilePath + ".jpeg";
+                exec.execFile("gs", ["-sDEVICE=jpeg","-dNOPAUSE", "-dJPEGQ=80", "-dBATCH", "-dFirstPage=1", "-dLastPage=1", "-sOutputFile=" + firstImagePath, "-r90", "-f", localFilePath], {}, function(error, stdout, stderr) {
+                  if(error === null) {
+                    resizeAndUploadImage(a, mimeType, size, fileName + ".jpeg", fileName, firstImagePath, localFilePath, function(err, a) {
+                      fs.unlink(firstImagePath, function (err) {
+                        payloadCallback(err, a);
+                      });
+                    });
+                  } else {
+                    payloadCallback(error, null);
+                  }
+                });
+
+              } else if(mimeType == "image/gif") {
+                //gifs are buggy after convertion, so we should not convert them
+
+                var s3Key = "s"+ a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName;
+
+                uploader.uploadFile(s3Key, "image/gif", localFilePath, function(err, url) {
+                  if (err) payloadCallback(err);
+                  else {
+                    console.log(localFilePath);
+                    var stats = fs.statSync(localFilePath);
+
+                    a.state = "idle";
+                    a.mime = mimeType;
+
+                    a.payload_size = stats["size"];
+                    a.payload_thumbnail_web_uri = url;
+                    a.payload_thumbnail_medium_uri = url;
+                    a.payload_thumbnail_big_uri = url;
+                    a.payload_uri = url;
+
+                    var factor = 320/size.width;
+                    a.w = Math.round(size.width*factor);
+                    a.h = Math.round(size.height*factor);
+
+                    a.updated_at = new Date();
+                    db.packArtifact(a);
+
+                    a.save().then(function() {
+                      fs.unlink(localFilePath, function (err) {
+                        if (err){
+                          console.error(err);
+                          payloadCallback(err, null);
+                        } else {
+                          console.log('successfully deleted ' + localFilePath);
+                          payloadCallback(null, a);
+                        }
+                      });
+                    });
+                  }
+                });
+
+              } else {
+                resizeAndUploadImage(a, mimeType, size, fileName, fileName, localFilePath, localFilePath, payloadCallback);
+              }
+            } else payloadCallback(err);
+          });            
+      
+        } else if (convertableVideoTypes.indexOf(mimeType) > -1) {
+          async.parallel({
+            thumbnail: function(callback) {
+              createThumbnailForVideo(fileName, localFilePath, function(err, created){
+                console.log("thumbnail created: ", err, created);
+                if (err) callback(err);
+                else {
+                  var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName + ".jpg" ;
+                  uploader.uploadFile(keyName, "image/jpeg", created, function(err, url){
+                    if (err) callback(err);
+                    else callback(null, url);
+                  });
+                }
+              });
+            },
+            ogg: function(callback) {
+              if (mimeType == "video/ogg") {
+                callback(null, "org");
+              } else {
+                convertVideo(fileName, localFilePath, "ogg", function(err, file) {
+                  if(err) callback(err);
+                  else {
+                    var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName + ".ogv" ;
+                    uploader.uploadFile(keyName, "video/ogg", file, function(err, url){
+                      if (err) callback(err);
+                      else callback(null, url);
+                    });
+                  }
+                }, progressCallback);
+              }
+            },
+            mp4: function(callback) {
+              if (mimeType == "video/mp4") {
+                callback(null, "org");
+              } else {
+                convertVideo(fileName, localFilePath, "mp4", function(err, file) {
+                  if (err) callback(err);
+                  else {
+                    var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName + ".mp4";
+                    uploader.uploadFile(keyName, "video/mp4" ,file, function(err, url) {
+                      if (err) callback(err);
+                      else callback(null, url);
+                    });
+                  }
+                }, progressCallback);
+              }
+            },
+            original: function(callback){
+              uploader.uploadFile(fileName, mimeType, localFilePath, function(err, url){
+                callback(null, url);
+              });
+            }
+          }, function(err, results) {
+            console.log(err, results);
+
+            if (err) payloadCallback(err, a);
+            else {
+              a.state = "idle";
+              a.mime = mimeType;
+              var stats = fs.statSync(localFilePath);
+
+              a.payload_size = stats["size"];
+              a.payload_thumbnail_web_uri = results.thumbnail;
+              a.payload_thumbnail_medium_uri = results.thumbnail;
+              a.payload_thumbnail_big_uri = results.thumbnail;
+              a.payload_uri = results.original;
+
+              if (mimeType == "video/mp4") {
+                a.payload_alternatives = [
+                  {
+                    mime: "video/ogg",
+                    payload_uri: results.ogg
+                  }
+                ];
+              } else {
+                a.payload_alternatives = [
+                  {
+                    mime: "video/mp4",
+                    payload_uri: results.mp4
+                  }
+                ];
+              }
+
+              db.packArtifact(a);
+
+              a.updated_at = new Date();
+              a.save().then(function() {
+                fs.unlink(localFilePath, function (err) {
+                  if (err) {
+                    console.error(err);
+                    payloadCallback(err, null);
+                  } else {
+                    console.log('successfully deleted ' + localFilePath);
+                    payloadCallback(null, a);
+                  }
+                });
+              });
+            }
+          });
+
+        } else if (convertableAudioTypes.indexOf(mimeType) > -1) {
+
+          async.parallel({
+            ogg: function(callback) {
+              convertAudio(fileName, localFilePath, "ogg", function(err, file) {
+                if(err) callback(err);
+                else {
+                  var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName + ".ogg" ;
+                  uploader.uploadFile(keyName, "audio/ogg", file, function(err, url){
+                    if (err) callback(err);
+                    else callback(null, url);
+                  });
+                }
+              });
+            },
+            mp3_waveform: function(callback) {
+              convertAudio(fileName, localFilePath, "mp3", function(err, file) {
+                if(err) callback(err);
+                else {
+
+                  createWaveform(fileName, file, function(err, filePath){
+
+                    var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName + "-" + (new Date().getTime()) + ".png";
+                    uploader.uploadFile(keyName, "image/png", filePath, function(err, pngUrl){
+
+                      var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName + ".mp3" ;
+                      uploader.uploadFile(keyName, "audio/mp3", file, function(err, mp3Url){
+                        if (err) callback(err);
+                        else callback(null, {waveform: pngUrl, mp3: mp3Url});
+                      });
+
+                    });
+                  });
+                }
+              });
+            },
+            original: function(callback) {
+              var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName;
+              uploader.uploadFile(keyName, mimeType, localFilePath, function(err, url){
+                callback(null, url);
+              });
+            }
+          }, function(err, results) {
+            console.log(err, results);
+
+            if (err) payloadCallback(err, a);
+            else {
+
+              a.state = "idle";
+              a.mime = mimeType;
+              var stats = fs.statSync(localFilePath);
+
+              a.payload_size = stats["size"];
+              a.payload_thumbnail_web_uri = results.mp3_waveform.waveform;
+              a.payload_thumbnail_medium_uri = results.mp3_waveform.waveform;
+              a.payload_thumbnail_big_uri = results.mp3_waveform.waveform;
+              a.payload_uri = results.original;
+              a.payload_alternatives = [
+                {payload_uri:results.ogg, mime:"audio/ogg"},
+                {payload_uri:results.mp3_waveform.mp3, mime:"audio/mpeg"}
+              ];
+
+              a.updated_at = new Date();
+
+              db.packArtifact(a);
+              
+              a.save().then(function(){
+                fs.unlink(localFilePath, function (err) {
+                  if (err){
+                    console.error(err);
+                    payloadCallback(err, null);
+                  } else {
+                    console.log('successfully deleted ' + localFilePath);
+                    payloadCallback(null, a);
+                  }
+                });
+              });
+            }
+          });
+
+
+        } else {
+          console.log("mimeType not matched for conversion, storing file");
+          var keyName = "s" + a.space_id.toString() + "/a" + a._id.toString() + "/" + fileName;
+          uploader.uploadFile(keyName, mimeType, localFilePath, function(err, url) {
+            
+            a.state = "idle";
+            a.mime = mimeType;
+            var stats = fs.statSync(localFilePath);
+            a.payload_size = stats["size"];
+            a.payload_uri = url;
+            
+            a.updated_at = new Date();
+            a.save().then(function() {
+              fs.unlink(localFilePath, function (err) {
+                payloadCallback(null, a);
+              });
+            });
+          });
+        }
+      } else {
+        //there was an error getting mime
+        payloadCallback(err);
+      }
+    });
+  }
+};
+
+

+ 128 - 0
helpers/importer.js

@@ -0,0 +1,128 @@
+'use strict';
+
+const extract = require('extract-zip')
+const config = require('config')
+const fs = require('fs')
+const path = require('path')
+
+const db = require('../models/db')
+const Sequelize = require('sequelize')
+const Op = Sequelize.Op
+const uuidv4 = require('uuid/v4')
+
+require('../models/db')
+
+module.exports = {
+  importZIP: function(user, zipPath) {
+
+    // 1. extract zip to local storage folder
+    // 2. read spaces.json from this folder
+    // 3. iterate through spaces and read all their artifact jsons
+    // 4. fixup storage paths
+    // 5. replace creator id by user._id
+
+    let relativeImportDir = 'import_'+user._id
+    let importDir = path.resolve(config.get('storage_local_path')+'/'+config.get('storage_bucket')+'/'+relativeImportDir)
+    
+    if (!fs.existsSync(importDir)) {
+      fs.mkdirSync(importDir)
+    }
+  
+    extract(zipPath, {dir: importDir}, function(err) {
+      if (err) {
+        console.log(err)
+        return
+      }
+      console.log('[import] extracted to',importDir)
+
+      let spacesJson = fs.readFileSync(importDir+'/spaces.json')
+      let spaces = JSON.parse(spacesJson)
+      var homeFolderId = null
+
+      console.log('[import] spaces:',spaces.length)
+
+      // pass 1: find homefolder
+      for (var i=0; i<spaces.length; i++) {
+        let space = spaces[i]
+        if (!space.parent_space_id) {
+          homeFolderId = space._id
+          break
+        }
+      }
+
+      console.log("[import] homeFolderId:",homeFolderId)
+
+      for (var i=0; i<spaces.length; i++) {
+        let space = spaces[i]
+
+        if (space.parent_space_id) {
+          let artifacts = JSON.parse(fs.readFileSync(importDir+'/'+space._id+'_artifacts.json'))
+          console.log('[import] space',space._id,'artifacts:',artifacts.length)
+
+          //let q = {where: {_id: space._id}}
+          space.creator_id = user._id
+          delete space.__v
+          
+          // transplant homefolder
+          console.log("parent:",space.parent_space_id)
+          if (space.parent_space_id+"" == homeFolderId+"") {
+            space.parent_space_id = user.home_folder_id
+          }
+
+          // move nested attrs
+          console.log(space)
+          for (k in space.advanced) {
+            space[k] = space.advanced[k]
+          }
+
+          db.Space.create(space)
+            .error((err) => {
+              console.error("[import] space upsert err:",err)
+            })
+          
+          for (var j=0; j<artifacts.length; j++) {
+            let a = artifacts[j]
+            
+            let q = {_id: a._id}
+            a.user_id = user._id
+            delete a.__v
+            delete a.payload_thumbnail_big_uri
+            
+            // move nested attrs
+            for (k in a.style) {
+              a[k] = a.style[k]
+            }
+            for (k in a.meta) {
+              a[k] = a.meta[k]
+            }
+            for (k in a.board) {
+              a[k] = a.board[k]
+            }
+
+            let prefix = "/storage/"+relativeImportDir+"/"+space._id+"_files/"
+            if (a.thumbnail_uri && a.thumbnail_uri[0]!='/') a.thumbnail_uri = prefix + a.thumbnail_uri
+            if (a.payload_uri && a.payload_uri[0]!='/') a.payload_uri = prefix + a.payload_uri
+            if (a.payload_thumbnail_web_uri && a.payload_thumbnail_web_uri[0]!='/') a.payload_thumbnail_web_uri = prefix + a.payload_thumbnail_web_uri
+            if (a.payload_thumbnail_medium_uri && a.payload_thumbnail_medium_uri[0]!='/') a.payload_thumbnail_medium_uri = prefix + a.payload_thumbnail_medium_uri
+
+            if (a.payload_alternatives) {
+              for (var k=0; k<a.payload_alternatives.length; k++) {
+                let alt = a.payload_alternatives[k]
+                
+                if (alt.payload_uri && alt.payload_uri[0]!='/') alt.payload_uri = prefix + alt.payload_uri
+                if (alt.payload_thumbnail_web_uri && alt.payload_thumbnail_web_uri[0]!='/') alt.payload_thumbnail_web_uri = prefix + alt.payload_thumbnail_web_uri
+                if (alt.payload_thumbnail_medium_uri && alt.payload_thumbnail_medium_uri[0]!='/') alt.payload_thumbnail_medium_uri = prefix + alt.payload_thumbnail_medium_uri
+              }
+            }
+
+            db.packArtifact(a)
+
+            db.Artifact.create(a).error(function(err) {
+              console.error("[import] artifact upsert err:",err)
+            })
+          }
+        }
+      }
+    })
+  }
+}

+ 93 - 0
helpers/mailer.js

@@ -0,0 +1,93 @@
+'use strict';
+
+const config = require('config');
+const nodemailer = require('nodemailer');
+const swig = require('swig');
+//var AWS = require('aws-sdk');
+
+module.exports = {
+  sendMail: (to_email, subject, body, options) => {
+    if (!options) {
+      options = {};
+    }
+
+    const teamname = options.teamname || config.get('team_name');
+    const from = teamname + ' <' + config.get('contact_email') + '>';
+
+    let reply_to = [from];
+    if (options.reply_to) {
+      reply_to = [options.reply_to];
+    }
+
+    let plaintext = body;
+    if (options.action && options.action.link) {
+      plaintext+="\n"+options.action.link+"\n\n";
+    }
+
+    const htmlText = swig.renderFile('./views/emails/action.html', {
+      text: body.replace(/(?:\n)/g, '<br />'),
+      options: options
+    });
+
+    if (config.get('mail_provider') === 'console') {
+
+      console.log("Email: to " + to_email + " in production.\nreply_to: " + reply_to + "\nsubject: " + subject + "\nbody: \n" + htmlText + "\n\n plaintext:\n" + plaintext);
+
+    } else if (config.get('mail_provider') === 'smtp') {
+
+      const transporter = nodemailer.createTransport({
+        host: config.get('mail_smtp_host'),
+        port: config.get('mail_smtp_port'),
+        secure: config.get('mail_smtp_secure'),
+        requireTLS: config.get('mail_smtp_require_tls'),
+        auth: {
+          user: config.get('mail_smtp_user'),
+          pass: config.get('mail_smtp_pass'),
+        }
+      });
+
+      transporter.sendMail({
+        from: from,
+        replyTo: reply_to,
+        to: to_email,
+        subject: subject,
+        text: plaintext,
+        html: htmlText,
+      }, function(err, info) {
+        if (err) {
+          console.error("Error sending email:", err);
+        } else {
+          console.log("Email sent.");
+        }
+      });
+
+    } else if (config.get('mail_provider') === 'aws') {
+      /*
+      AWS.config.update({region: 'eu-west-1'});
+      var ses = new AWS.SES();
+
+      ses.sendEmail( {
+        Source: from,
+        Destination: { ToAddresses: [to_email] },
+        ReplyToAddresses: reply_to,
+        Message: {
+          Subject: {
+            Data: subject
+          },
+          Body: {
+            Text: {
+              Data: plaintext,
+            },
+            Html: {
+              Data: htmlText
+            }
+          }
+        }
+      }, function(err, data) {
+        if (err) console.error("Error sending email:", err);
+        else console.log("Email sent.");
+      });
+      */
+    }
+  }
+};

+ 70 - 0
helpers/phantom.js

@@ -0,0 +1,70 @@
+'use strict';
+
+const db = require('../models/db');
+const config = require('config');
+const phantom = require('node-phantom-simple');
+const os = require('os');
+
+module.exports = {
+  // type = "pdf" or "png"
+  takeScreenshot: function(space,type,on_success,on_error) {
+    var spaceId = space._id;
+    var space_url = config.get("endpoint")+"/api/spaces/"+spaceId+"/html";
+
+    var export_path = os.tmpdir()+"/"+spaceId+"."+type;
+
+    var timeout = 5000;
+    if (type=="pdf") timeout = 30000;
+
+    space_url += "?api_token="+config.get("phantom_api_secret");
+
+    console.log("[space-screenshot] url: "+space_url);
+    console.log("[space-screenshot] export_path: "+export_path);
+
+    var on_success_called = false;
+
+    var on_exit = function(exit_code) {
+      if (exit_code>0) {
+        console.error("phantom abnormal exit for url "+space_url);
+        if (!on_success_called && on_error) {
+          on_error();
+        }
+      }
+    };
+
+    phantom.create({ path: require('phantomjs-prebuilt').path }, function (err, browser) {
+      if (err) {
+        console.error(err);
+      } else {
+        return browser.createPage(function (err, page) {
+          console.log("page created, opening ",space_url);
+
+          if (type=="pdf") {
+            var psz = {
+              width: space.width+"px",
+              height: space.height+"px"
+            };
+            page.set('paperSize', psz);
+          }
+
+          page.set('settings.resourceTimeout',timeout);
+          page.set('settings.javascriptEnabled',false);
+
+          return page.open(space_url, function (err,status) {
+            page.render(export_path, function() {
+              on_success_called = true;
+              if (on_success) {
+                on_success(export_path);
+              }
+              page.close();
+              browser.exit();
+            });
+          });
+        });        
+      }
+
+    }, {
+      onExit: on_exit
+    });
+  }
+};

+ 161 - 0
helpers/redis.js

@@ -0,0 +1,161 @@
+'use strict';
+
+const config = require('config');
+
+// this is a mock version of the Redis API,
+// emulating Redis if it is not available locally
+var notRedis = {
+  state: {},
+  topics: {},
+  
+  publish: function(topic, msg, cb) {
+    if (!this.topics[topic]) {
+      this.topics[topic] = {
+        subscribers: []
+      };
+    }
+    var t=this.topics[topic];
+    for (var i=0; i<t.subscribers.length; i++) {
+      var s=t.subscribers[i];
+      if (s.handler) {
+        s.handler(topic, msg);
+      }
+    }
+    if (cb) cb(null);
+  },
+
+  subscribe: function(topics, cb) {
+    var handle = {
+      handler: null,
+      on: function(evt, cb) {
+        if (evt == "message") {
+          this.handler = cb;
+        }
+      }
+    };
+    
+    for (var i=0; i<topics.length; i++) {
+      var topic = topics[i];
+      if (!this.topics[topic]) {
+        this.topics[topic] = {
+          subscribers: []
+        };
+      }
+    
+      var t=this.topics[topic];
+      t.subscribers.push(handle);
+    }
+
+    cb(null, topics.length);
+    return handle;
+  },
+
+  get: function(key, cb) {
+    cb(null, this.state[key]);
+    return this.state[key];
+  },
+
+  set: function(key, val, cb) {
+    this.state[key] = val;
+    cb();
+  },
+
+  del: function(key, cb) {
+    delete this.state[key];
+    cb(null);
+  },
+
+  sadd: function(key, skey, cb) {
+    if (!this.state[key]) this.state[key] = {};
+    this.state[key][skey] = true;
+    cb(null);
+  },
+
+  srem: function(key, skey, cb) {
+    if (this.state[key]) {
+      delete this.state[key][skey];
+    }
+    cb(null);
+  },
+
+  smembers: function(key, cb) {
+    cb(null, Object.keys(this.state[key]));
+  },
+
+  incr: function(key, cb) {
+    if (!this.state[key]) this.state[key] = 0;
+    this.state[key]++;
+    cb(null, this.state[key]);
+  },
+
+  expire: function() {
+  },
+}
+
+module.exports = {
+  connectRedis: function() {
+    if (config.get("redis_mock")) {
+      this.connection = notRedis;
+    } else {
+      const redisHost = process.env.REDIS_PORT_6379_TCP_ADDR || 'sync';
+      this.connection = new RedisConnection(6379, redisHost);
+    }
+  },
+  getConnection: function() {
+    this.connectRedis();
+    return this.connection;
+  },
+  sendMessage: function(action, model, attributes, channelId) {
+    const data = JSON.stringify({
+      channel_id: channelId,
+      action: action,
+      model: model,
+      object: attributes
+    });
+    this.connection.publish('updates', data);
+  },
+  logIp: function(ip, cb) {
+    this.connection.incr("ip_"+ ip, (err, socketCounter) => {
+      cb();
+    });
+  },
+  rateLimit: function(namespace, ip, cb) {
+    const key = "limit_"+ namespace + "_"+ ip;
+    const redis = this.connection;
+
+    redis.get(key, (err, count)=> {
+      if (count) {
+        if(count < 150) {
+          redis.incr(key, (err, newCount) => {
+            if (newCount==150) {
+              // limit
+            }
+            cb(true);
+          });
+        } else {
+          cb(false);
+        }
+      } else {
+        redis.set(key, 1, (err, count) => {
+          redis.expire(key, 1800, (err, expResult) => {
+            cb(true);
+          });
+        });
+      }
+    });
+  }, 
+  isOnlineInSpace: function(user, space, cb) {
+    this.connection.smembers("space_" + space._id.toString(), function(err, list) {
+      if (err) cb(err);
+      else {
+        var users = list.filter(function(item) {
+          return user._id.toString() === item;
+        });
+        cb(null, (users.length > 0));
+      }
+    });
+  }
+};
+
+return module.exports;
+

+ 149 - 0
helpers/space-render.js

@@ -0,0 +1,149 @@
+var fs = require('fs');
+var cheerio = require("cheerio");
+
+var artifact_vector_render = require("../public/javascripts/vector-render.js");
+
+global.render_vector_shape = artifact_vector_render.render_vector_shape;
+global.render_vector_drawing = artifact_vector_render.render_vector_drawing;
+
+var artifact_view_model = require("../public/javascripts/spacedeck_board_artifacts.js").SpacedeckBoardArtifacts;
+
+var template = fs.readFileSync("views/partials/space-isolated.html");
+
+var dom = cheerio.load(template);
+
+var compiled_js = "";
+
+function emit(str,indent) {
+  var spaces="";
+  for (var i=0; i<indent; i++) spaces+="  ";
+  compiled_js+=spaces+str;
+}
+
+function compile_expr(v) {
+  v=v.replace(/'/g,"\\'");
+  v=v.replace(/[\r\n]/g," ");
+
+  f=/\{([^\|\{]+)\|([^\}]+)\}/.exec(v);
+  if (f) {
+    v=v.replace(f[1]+"|"+f[2],f[2]+"("+f[1]+")");
+  }
+
+  // replace braces
+  v=v.replace(/\{\{\{?/g,"'+");
+  v=v.replace(/\}\}\}?/g,"+'");
+  return v;
+}
+
+var iterators = 0;
+
+function walk(n,indent) {
+  if (n.type == "tag") {
+    //console.log("n: ",n.type,n.name,n.attribs);
+  }
+
+  var braces = 0;
+
+  if (n.type == "text") {
+    if (n.data.match(/[a-zA-Z0-9\{]+/)) {
+      emit("h+='"+compile_expr(n.data)+"';",indent);
+    }
+  }
+  else if (n.type == "tag") {
+    var attrs = [];
+
+    var keys = Object.keys(n.attribs);
+
+    for (var i=0; i<keys.length; i++) {
+      var k = keys[i];
+      var v = n.attribs[k];
+
+      if (k.substring(0,2) == "v-") {
+        // vue attribute
+        if (k.match("v-if")) {
+          var test = emit("if ("+v+") {",indent);
+          braces++;
+          indent++;
+        }
+        else if (k.match("v-repeat")) {
+          var parts = v.split("|")[0].split(":");
+          var left = parts[0].replace(/ /g,"");
+          var right = parts[1].replace(/ /g,"");
+          iterators++;
+
+          emit("for (var i"+iterators+"=0;i"+iterators+"<"+right+".length;i"+iterators+"++) {",indent);
+          emit("var "+left+"="+right+"[i"+iterators+"];",indent+1);
+          braces++;
+          indent++;
+        }
+      } else {
+        v=compile_expr(v);
+
+        attrs.push(k+"=\""+v+"\"");
+      }
+    }
+
+    emit("h+='<"+n.name+" "+attrs.join(" ")+">';",indent);
+
+    for (var i=0; i<n.children.length; i++) {
+      walk(n.children[i],indent);
+    }
+
+    emit("h+='</"+n.name+">';", indent);
+
+    for (var i=braces; i>0; i--) {
+      indent--;
+      emit("}",indent);
+    }
+  }
+}
+
+function render_space_as_html(space, artifacts) {
+  if (!compiled_js.length) {
+    walk(dom("#space")[0],0);
+    //console.log("compiled template: \n"+compiled_js);
+  }
+  
+  // --------
+  var mouse_state = "idle";
+  var active_tool = "pointer";
+  var active_space = space;
+  var active_space_artifacts = artifacts;
+
+  var bounds_zoom = 1;
+  var bounds_margin_horiz = 0;
+  var bounds_margin_vert = 0;
+  var viewport_zoom = 1;
+  // --------
+
+  var editing_artifact_id = null;
+  var urls_to_links = function(html) {
+    return html;
+  }
+
+  artifact_view_model.selected_artifacts_dict = {};
+
+  for (var i=0; i<active_space_artifacts.length; i++) {
+    var a = active_space_artifacts[i];
+    artifact_view_model.update_board_artifact_viewmodel(a);
+    if (!a.description) a.description = "";
+    if (!a.title) a.title = "";
+  }
+
+  var h="";
+  try {
+    eval(compiled_js);
+  } catch (e) {
+    console.error("error rendering space "+space._id+" as html: "+e);
+  }
+  
+  var style="html, body, #space { overflow: visible !important; }\n";
+  style+=".wrapper { border: none !important; }\n";
+
+  h='<html>\n<head>\n<link href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,700,600,800,300|Montserrat:400,700|EB+Garamond|Vollkorn|Fire+Sans|Lato|Roboto|Source+Code+Pro|Ubuntu|Raleway|Playfair+Display|Crimson+Text" rel="stylesheet" type="text/css">\n<link type="text/css" rel="stylesheet" href="https://fast.fonts.net/cssapi/ee1a3484-4d98-4f9f-9f55-020a7b37f3c5.css"/>\n<link rel="stylesheet" href="/stylesheets/style.css"><style>'+style+'</style>\n</head>\n<body id="main">\n'+h+"\n</html>\n";
+
+  return h;
+}
+
+exports.render_space_as_html = render_space_as_html;
+

+ 84 - 0
helpers/uploader.js

@@ -0,0 +1,84 @@
+'use strict';
+
+var fs = require('fs');
+var config = require('config');
+var s3 = null;
+
+// use AWS S3 or local folder depending on config
+if (config.get("storage_local_path")) {
+  var AWS = require('mock-aws-s3');
+  AWS.config.basePath = config.get("storage_local_path");
+  s3 = new AWS.S3();
+} else {
+  var AWS = require('aws-sdk');
+  var storage_endpoint = config.get("storage_endpoint");
+  const ep = new AWS.Endpoint(storage_endpoint);
+
+  AWS.config.update(new AWS.Config({
+    accessKeyId: process.env.MINIO_ACCESS_KEY, 
+    secretAccessKey: process.env.MINIO_SECRET_KEY, 
+    region: config.get("storage_region"),
+    s3ForcePathStyle: true,
+    signatureVersion: 'v4'
+  }));
+  s3 = new AWS.S3({
+    endpoint: ep
+  });
+}
+
+s3.createBucket({
+  Bucket: config.get("storage_bucket"),
+  ACL: "public-read",
+  GrantRead: "*"
+}, (err,res) => {
+  console.log("createBucket",err,res);
+});
+
+module.exports = {
+  removeFile: (path, callback) => {
+    const bucket = config.get("storage_bucket");
+    s3.deleteObject({
+      Bucket: bucket, Key: path
+    }, (err, res) => {
+      if (err){
+        console.error(err);
+        callback(err);
+      }else {
+        callback(null, res);
+      }
+    });
+  },
+  uploadFile: function(fileName, mime, localFilePath, callback) {
+    if (typeof(localFilePath)!="string") {
+      callback({error:"missing path"}, null);
+      return;
+    }
+    console.log("[storage] uploading", localFilePath, " to ", fileName);
+
+    const bucket = config.get("storage_bucket");
+    const fileStream = fs.createReadStream(localFilePath);
+    fileStream.on('error', function (err) {
+      if (err) {
+        console.error(err);
+        callback(err);
+      }
+    });
+    fileStream.on('open', function () {
+      s3.putObject({
+        Bucket: bucket,
+        Key: fileName,
+        ContentType: mime,
+        Body: fileStream
+      }, function (err) {
+        if (err){
+          console.error(err);
+          callback(err);
+        } else {
+          const url = config.get("storage_cdn") + "/" + fileName;
+          console.log("[s3]" + localFilePath + " to " + url);
+          callback(null, url);
+        }
+      });
+    });
+  }
+};

+ 307 - 0
helpers/websockets.js

@@ -0,0 +1,307 @@
+'use strict';
+
+const db = require('../models/db');
+const Sequelize = require('sequelize');
+const Op = Sequelize.Op;
+
+const config = require('config');
+
+const WebSocketServer = require('ws').Server;
+
+//const RedisConnection = require('ioredis');
+const async = require('async');
+const _ = require("underscore");
+const crypto = require('crypto');
+
+const redisMock = require("./redis.js");
+
+module.exports = {
+  startWebsockets: function(server) {
+    this.setupSubscription();
+    
+    if (!this.current_websockets) {
+      if (config.get("redis_mock")) {
+        this.state = redisMock.getConnection();
+      } else {
+        this.state = new RedisConnection(6379, process.env.REDIS_PORT_6379_TCP_ADDR || config.get("redis_host"));
+      }
+      this.current_websockets = [];
+    }
+
+    const wss = new WebSocketServer({ server:server,  path: "/socket" });
+    wss.on('connection', function(ws) {
+
+      this.state.incr("socket_id", function(err, socketCounter) {
+        const socketId = "socket_"  + socketCounter + "_" + crypto.randomBytes(64).toString('hex').substring(0,8);
+        const serverScope = this;
+
+        ws.on('message', function(msgString){
+          const socket = this;
+
+          const msg = JSON.parse(msgString);
+
+          if(msg.action == "auth"){
+
+            const token = msg.auth_token;
+            const editorName = msg.editor_name;
+            const editorAuth = msg.editor_auth;
+            const spaceId = msg.space_id;
+
+            db.Space.findOne({where: {"_id": spaceId}}).then(space => {
+              if (space) {
+                const upgradeSocket = function() {
+                  if (token) {
+                    db.findUserBySessionToken(token, function(err, user) {
+                      if (err) {
+                        console.error(err, user);
+                      } else {
+                        if (user) {
+                          serverScope.addUserInSpace(user._id, space, ws, function(err){
+                            serverScope.addLocalUser(user._id, ws);
+                            console.log("[websockets] user " + user.email + " online in space " +  space._id);
+                          });
+                        }
+                      }
+                    });
+                  } else {
+                    const anonymousUserId = space._id + "-" + editorName;
+
+                    if(space.access_mode == "private" && space.edit_hash != editorAuth){
+                      console.error("closing websocket: unauthed.");
+                      ws.send(JSON.stringify({error: "auth_failed"}));
+                      // ws.close();
+                      return;
+                    }
+
+                    serverScope.addUserInSpace(anonymousUserId, space, ws, function(err){
+                      serverScope.addLocalUser(anonymousUserId, ws);
+                      console.log("[websockets] anonymous user " + anonymousUserId + " online in space " +  space._id);
+                    });
+                  }
+                };
+
+                if (!ws.id) {
+                  ws['id'] = socketId;
+                  try {
+                    ws.send(JSON.stringify({"action": "init", "channel_id": socketId}));
+                  } catch (e) {
+                    console.log("ws.send error: "+e);
+                  }
+                }
+
+                if (ws.space_id) {
+                  serverScope.removeUserInSpace(ws.space_id, ws, function(err) {
+                    upgradeSocket();
+                  });
+                } else {
+                  upgradeSocket();
+                }
+              } else {
+                ws.send(JSON.stringify({error: "space not found"}));
+                ws.close();
+                return;
+              }
+            });
+
+          } else if (msg.action == "cursor" || msg.action == "viewport" || msg.action=="media") {
+            msg.space_id = socket.space_id;
+            msg.from_socket_id = socket.id;
+            serverScope.state.publish('cursors', JSON.stringify(msg));
+          }
+        });
+
+        ws.on('close', function(evt) {
+          console.log("websocket closed: ", ws.id, ws.space_id);
+          const spaceId = ws.space_id;
+          serverScope.removeUserInSpace(spaceId, ws, function(err) {
+            this.removeLocalUser(ws, function(err) {
+            }.bind(this));
+          }.bind(this));
+        }.bind(this));
+
+        ws.on('error', function(ws, err) {
+          console.error(err, res);
+        }.bind(this));
+      }.bind(this));
+    }.bind(this));
+  },
+
+  setupSubscription: function() {
+    if (config.get("redis_mock")) {
+      this.cursorSubscriber = redisMock.getConnection().subscribe(['cursors', 'users', 'updates'], function (err, count) {
+        console.log("[redis-mock] websockets subscribed to " + count + " topics." );
+      });
+    } else {
+      this.cursorSubscriber = new RedisConnection(6379, process.env.REDIS_PORT_6379_TCP_ADDR || config.get("redis_host"));
+      this.cursorSubscriber.subscribe(['cursors', 'users', 'updates'], function (err, count) {
+        console.log("[redis] websockets subscribed to " + count + " topics." );
+      });
+    }
+    
+    this.cursorSubscriber.on('message', function (channel, rawMessage) {
+      const msg = JSON.parse(rawMessage);
+      const spaceId = msg.space_id;
+
+      const websockets = this.current_websockets;
+
+      if(channel === "updates") {
+        for(let i=0;i<websockets.length;i++) {
+          const ws = websockets[i];
+          if(ws.readyState === 1) {
+            ws.send(JSON.stringify(msg));
+          }
+        }
+      } else if(channel === "users") {
+        const usersList = msg.users;
+
+        if (usersList) {
+          for(let i=0;i<usersList.length;i++) {
+            const activeUser = usersList[i];
+            let user_id;
+
+            if (activeUser._id) {
+              user_id = activeUser._id;
+            } else {
+              user_id = spaceId + "-" + (activeUser.nickname||"anonymous");
+            }
+
+            for (let a=0; a < websockets.length; a++) {
+              const ws = websockets[a];
+              if(ws.readyState === 1){
+                if(ws.space_id == spaceId) {
+                  ws.send(JSON.stringify({"action": "status_update", space_id: spaceId, users: usersList}));
+                } else {
+                  //console.log("space id not matching", spaceId, ws.space_id);
+                }
+
+              } else {
+                // FIXME SHOULD CLEANUP SOCKET HERE
+                console.error("socket in wrong state", ws.readyState);
+                if(ws.readyState == 3) {
+                  this.removeLocalUser(ws, (err) => {
+                    console.log("old websocket removed");
+                  });
+                }
+              }
+            }
+          }
+        } else {
+          console.error("userlist undefined for websocket");
+        }
+      } else if(channel === "cursors") {
+        const socketId = msg.from_socket_id;
+        for (let i=0;i<websockets.length;i++) {
+          const ws = websockets[i];
+          if (ws.readyState === 1) {
+            if (ws.space_id && spaceId) {
+              if ((ws.space_id == spaceId) && (ws.id !== socketId)) {
+                ws.send(JSON.stringify(msg));
+              }
+            } else {
+              console.log("space id not set, ignoring");
+            }
+          }
+        }
+      }
+    }.bind(this));
+  },
+
+  addLocalUser: function(username, ws) {
+    if (ws.added) {
+      return;
+    }
+    ws.added = true;
+    this.current_websockets.push(ws);
+  },
+  
+  removeLocalUser: function(ws, cb) {
+    const idx = this.current_websockets.indexOf(ws);
+    if(idx > -1) {
+      this.removed_items = this.current_websockets.splice(idx, 1);
+      console.log("removed local socket, current online on this process: ", this.current_websockets.length);
+    } else {
+      console.log("websocket not found to remove");
+    }
+
+    this.state.del(ws.id+"", function(err, res) {
+      if (err) console.error(err, res);
+      else {
+        this.removeUserInSpace(ws.space_id, ws, (err) => {
+          console.log("removed user from space list");
+          this.distributeUsers(ws.space_id);
+        })
+        if(cb)
+          cb(err);
+      }
+    }.bind(this));
+  },
+  
+  addUserInSpace: function(username, space, ws, cb) {
+    console.log("[websockets] user "+username+" in "+space.access_mode +" space " +  space._id + " with socket "  +  ws.id);
+    
+    this.state.set(ws.id+"", username+"", function(err, res) {
+      if(err) console.error(err, res);
+      else {
+        this.state.sadd("space_" + space._id, ws.id, function(err, res) {
+          if(err) cb(err);
+          else {
+            ws['space_id'] = space._id.toString();
+
+            this.distributeUsers(ws.space_id);
+            if(cb)
+              cb();
+          }
+        }.bind(this));
+      }
+    }.bind(this));
+  },
+  removeUserInSpace: function(spaceId, ws, cb) {
+    this.state.srem("space_" + spaceId, ws.id+"", function(err, res) {
+      if (err) cb(err);
+      else {
+        console.log("[websockets] socket "+  ws.id + " went offline in space " + spaceId);
+        this.distributeUsers(spaceId);
+        ws['space_id'] = null;
+
+        if (cb)
+          cb();
+      }
+    }.bind(this));
+  },
+
+  distributeUsers: function(spaceId) {
+    if (!spaceId)
+      return;
+
+    /*this.state.smembers("space_" + spaceId, function(err, list) {
+      async.map(list, function(item, callback) {
+        this.state.get(item, function(err, userId) {
+          console.log(item, "->", userId);
+          callback(null, userId);
+        });
+      }.bind(this), function(err, userIds) {
+        const uniqueUserIds = _.unique(userIds);
+        const validUserIds = _.filter(uniqueUserIds, function(uId) {
+          return mongoose.Types.ObjectId.isValid(uId);
+        });
+
+        const nonValidUserIds = _.filter(uniqueUserIds, function(uId) {
+          return (uId !== null && !mongoose.Types.ObjectId.isValid(uId));
+        });
+
+        const anonymousUsers = _.map(nonValidUserIds, function(nonValidId) {
+          const realNickname = nonValidId.slice(nonValidId.indexOf("-")+1);
+          return {nickname: realNickname, email: null, avatar_thumbnail_uri: null };
+        });
+
+        db.User.findAll({where: {
+          "_id" : { "$in" : validUserIds }}, attributes: ["nickname","email","avatar_thumbnail_uri"]})
+          .then(users) {
+            const allUsers = users.concat(anonymousUsers);
+            const strUsers = JSON.stringify({users: allUsers, space_id: spaceId});
+            this.state.publish("users", strUsers);
+          }.bind(this));
+      }.bind(this));
+    }.bind(this));*/
+  }
+};

+ 321 - 0
locales/de.js

@@ -0,0 +1,321 @@
+{
+	"lang": "de",
+	"ok": "OK",
+	"cancel": "Abbrechen",
+	"close": "Schließen",
+	"open": "Öffnen",
+	"folder": "Ordner",
+	"duplicate": "Duplizieren",
+	"save": "Speichern",
+	"saved": "Gespeichert",
+	"created": "Erstellt",
+	"delete": "Löschen",
+	"remove": "Entfernen",
+	"set": "Übernehmen",
+	"reset": "Zurücksetzen",
+	"thanks": "Danke",
+	"share": "Teilen",
+	"signup": "Registrieren",
+	"login": "Anmelden",
+	"logout": "Abmelden",
+	"email": "E-Mail-Adresse",
+	"password": "Passwort",
+	"width": "Breite",
+	"height": "Höhe",
+	"nick": "Benutzername",
+	"role": "Rolle",
+	"members": "Mitglieder",
+	"actions": "Aktionen",
+	"or": "oder",
+	"you": "du",
+	"via": "via",
+	"by": "von",
+	"new": "Neu",
+	"zero": "Null",
+	"page": "Seite",
+	"copy": "Kopie",
+	"home": "Übersicht",
+	"owner": "Besitzer",
+	"space": "Space",
+	"second": "Sekunde",
+	"not_found": "Nicht Gefunden.",
+	"untitled_space": "Unbenannter Space",
+	"untitled_folder": "Unbenannter Order",
+	"untitled": "Unbenannter",
+	"sure": "Bist du sicher?",
+	"specify": "Bitte spezifiziere",
+	"confirm": "Bitte bestätige",
+	"signup_google": "Mit Google anmelden",
+	"error_unknown_email": "Unbekannte Kombination von Email und Passwort.",
+	"error_password_confirmation": "Die beiden Passwörter stimmen nicht überein.",
+	"error_domain_blocked": "Diese Domain ist gesperrt.",
+	"error_user_email_already_used": "Diese Email-Adresse ist bereits registriert.",
+	"support": "Spacedeck-Support",
+	"offline": "Verbindungsverlust. Mehr Infos hier.",
+	"error": "Entschuldigung, etwas ist schiefgegangen. Bitte kontaktiere support@spacedeck.com",
+	"welcome": "Willkommen",
+	"claim": "Dein digitales Whiteboard.",
+	"trynow": "Jetzt probieren.",
+	"about": "Über uns",
+	"terms": "AGBs",
+	"contact": "Kontakt",
+	"privacy": "Privatsphäre",
+	"post_adress": "Postadresse",
+	"phone": "Phone",
+	"business_address": "business_address",
+	"ceo": "Geschäftsführer",
+	"business_adress": "business_adress",
+	"title": "Titel",
+	"name": "Name",
+	"confirm_subject": "E-Mail Bestätigung für Spacedeck",
+	"confirm_body": "Danke, dass du dich bei Spacedeck angemeldet hast.\nBitte klicke auf den folgenden Link, um deine E-Mail Adresse zu bestätigen.\n ",
+	"confirm_action": "E-Mail Bestätigen",
+	"team_invite_membership_subject": "Einladung zu %s auf Spacedeck",
+	"team_invite_membership_body": "Du wurdest zu %s auf Spacedeck eingeladen. \n\nBitte klicke auf den folgenden Link, um die Einladung anzunehmen.\n",
+	"team_invite_user_body": "Du wurdest zu %s auf Spacedeck eingeladen. Dein temporäres Passwort ist \"%s\".\n Bitte klicke auf den folgenden Link, um die Einladung anzunehmen.",
+	"team_invite_admin_body": " %s wurde zu %s auf Spacedeck eingeladen. Das temporäres Passwort ist \"%s\".",
+	"team_invite_membership_action": "Annehmen",
+	"team_new_member_subject": "Neues Team Mitglied",
+	"team_new_member_body": "%s hat gerade seine Einladung zum Team %s angenommen.",
+	"space_invite_membership_subject": "Einladung von %s in Space %s",
+	"space_invite_membership_body": "Du wurdest von %s in den Space '%s' eingeladen.\nBitte klicke auf den folgenden Link um die Einladung anzunehmen.",
+	"space_invite_membership_action": "Annehmen",
+	"folder_invite_membership_subject": "Einladung von %s in Ordner %s",
+	"folder_invite_membership_body": "Du wurdest von %s in den Space '%s' eingeladen.\nBitte klicke auf den folgenden Link um die Einladung anzunehmen.",
+	"folder_invite_membership_action": "Accept",
+	"upgrade": "Upgrade",
+	"upgrade_now": "Jetzt Upgraden",
+	"create_space": "Space Erstellen",
+	"create_folder": "Ordner Erstellen",
+	"email_unconfirmed": "Email Unbestätigt",
+	"confirmation_sent": "Email Versandt",
+	"folder_filter": "Filter",
+	"sort_by": "Reihenfolge",
+	"last_modified": "Zuletzt Geändert",
+	"last_opened": "Zuletzt Geöffnet",
+	"edit_team": "Team Verwalten",
+	"edit_account": "Konto Bearbeiten",
+	"log_out": "Abmelden",
+	"no_spaces_yet": "Du hast noch keine Spaces erstellt.",
+	"new_folder_title": "Neuer Titel für Ordner",
+	"folder_settings": "Ordner-Einstellungen",
+	"upload_cover_image": "Ordnerbild Hochladen",
+	"spacedeck_pro_ad_folders": "Mit Spacedeck Pro kannst du beliebig viele Spaces in Ordnerstrukturen organisieren und für jeden Ordner Zugriffsrechte regeln. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_versions": "Mit Spacedeck Pro kannst du beliebig viele Versionen deines Spaces festhalten und später die Entwicklungsgeschichte nachvollziehen. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_pdf": "Mit Spacedeck Pro kannst du Spaces und sogar ganze Ordner als druckreife PDFs exportieren oder per Mail versenden. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_zip": "Mit Spacedeck Pro kannst du die Inhalte deiner Spaces jederzeit als ZIP-Paket exportieren. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_colors": "Spacedeck Pro enthält einen Profi-Farbmischer, mit dem du deine eigenen Farben mischen kannst.",
+	"profile_caption": "Profil",
+	"upload_avatar": "Profilbild Hochladen",
+	"uploading_avatar": "Profilbild wird hochgeladen…",
+	"avatar_dimensions": "Bestes Format: 200×200 Pixel.",
+	"profile_name": "Name",
+	"profile_email": "Email-Adresse",
+	"send_again": "Erneut Senden",
+	"confirmation_sent_long": "Email-Bestätigungslink versandt. Bitte überprüfe deine Mails.",
+	"confirmation_sent_another": "Wir haben eine weiteren Bestätigungslink versandt.",
+	"confirmation_sent_dialog_text": "Wir haben dir eine Email geschickt, die erklärt, wie das mit der Bestätigung läuft.",
+	"payment_caption": "Bezahlung",
+	"language_caption": "Sprache",
+	"notifications_caption": "Emails",
+	"notifications_option_chat": "Haltet mich über neue Kommentare auf dem Laufenden.",
+	"notifications_option_spaces": "Schickt mir täglich eine Zusammenfassung über Änderungen an meinen Spaces und Ordnern.",
+	"password_caption": "Passwort",
+	"current_password": "Altes Passwort",
+	"new_password": "Neues Passwort",
+	"verify_password": "Zur Sicherheit nochmal",
+	"change_password": "Passwort Ändern",
+	"reset_password": "Passwort Zurücksetzen",
+	"terminate_caption": "Kündigen",
+	"terminate_warning": "Wenn du kündigst, werden all deine Spaces, Ordner und Nachrichten und alle ihre Inhalte gelöscht.",
+	"terminate_warning2": "Das kann man nicht rückgängig machen.",
+	"terminate_reason": "Kündigungsgrund",
+	"terminate_reason_caption": "Wenn du uns mitteilst, was dich gestört hat, hilft uns das dabei ein besseres Produkt zu machen.",
+	"terminate_terminate": "Wirklich Kündigen",
+	"space_blank1": "Dies ist dein brandneuer, leerer Space!",
+	"space_blank2": "Wirf Dateien rein, paste Web-Links",
+	"space_blank3": "oder nutz die Werkzeuge da unten.",
+	"space_blank4": "Sei kreativ und tob dich aus!",
+	"draft": "Entwurf",
+	"publish": "Veröffentlichen",
+	"published": "Veröffentlicht",
+	"save_version": "Version Speichern",
+	"version_saved": "Version Gespeichert",
+	"post": "Abschicken",
+	"chat_invite_cta1": "Zusammen arbeiten macht Spaß!",
+	"chat_invite_cta2": "Warum ",
+	"chat_invite_cta3": "lädst du nicht ein paar Leute ein",
+	"chat_invite_cta4": "mit denen du dann zusammen arbeiten kannst?",
+	"chat_message_placeholder": "Schreib hier deine Nachricht…",
+	"view": "Ansicht",
+	"edit": "Bearb.",
+	"present": "Präse.",
+	"chat": "Chat",
+	"meta": "Teilen",
+	"tool_search": "Suche",
+	"tool_upload": "Upload",
+	"tool_text": "Text",
+	"tool_shape": "Grafik",
+	"tool_zones": "Zonen",
+	"tool_canvas": "Wand",
+	"search_media": "Medien im Web suchen…",
+	"type_here": "Hier was eingeben",
+	"text_formats": "Formate",
+	"format_p": "Absatz",
+	"format_bullets": "Bullet-Liste",
+	"format_numbers": "Nummerierte Liste",
+	"format_h1": "Überschrift 1",
+	"format_h2": "Überschrift 2",
+	"format_h3": "Überschrift 3",
+	"font_size": "Schriftgröße",
+	"line_height": "Zeilenhöhe",
+	"tool_align": "Bund",
+	"tool_styles": "Stil",
+	"tool_bullets": "Bullets",
+	"tool_numbers": "Zahlen",
+	"color_fill": "Füllung",
+	"color_stroke": "Strich",
+	"color_text": "Text",
+	"tool_type": "Typo",
+	"tool_box": "Box",
+	"tool_link": "Link",
+	"tool_layout": "Layout",
+	"tool_options": "Mehr",
+	"tool_stroke": "Strich",
+	"tool_delete": "Löschen",
+	"tool_lock": "Sperren",
+	"tool_copy": "Kopie",
+	"stack": "Anordnung",
+	"tool_circle": "Kreis",
+	"tool_hexagon": "Sechseck",
+	"tool_square": "Quadrat",
+	"tool_diamond": "Diamant",
+	"tool_bubble": "Blase",
+	"tool_cloud": "Wolke",
+	"tool_burst": "Burst",
+	"tool_star": "Stern",
+	"tool_heart": "Herz",
+	"tool_scribble": "Kritzeln",
+	"tool_line": "Linie",
+	"tool_arrow": "Pfeil",
+	"search_media_placeholder": "Online-Medien suchen…",
+	"add_zone": "Neue Zone",
+	"palette": "Palette",
+	"picker": "Mischen",
+	"background_image_caption": "Bild",
+	"background_color_caption": "Farbe",
+	"upload_background_caption": "Klicke hier, um ein Hintergrundbild hochzuladen.",
+	"upload_background": "Hintergrund Hochladen",
+	"access_caption": "Zugriff",
+	"versions_caption": "Versionen",
+	"info_caption": "Info",
+	"mode_private": "Privat: Nur Mitglieder können zugreifen",
+	"mode_public": "Öffentlich: Jede(r) mit Kenntnis des Links darf reinschauen",
+	"invite_collaborators": "Mitarbeiter Einladen",
+	"invitee_email_address": "Email-Adresse des neuen Mitglieds",
+	"optional_message": "Optionale Nachricht",
+	"revoke_access": "Zugriff Entfernen",
+	"invite": "Einladen",
+	"role_viewer": "Betrachter",
+	"role_editor": "Bearbeiter",
+	"role_admin": "Admin",
+	"new_space_title": "Neuer Titel für Space",
+	"logging_in": "logging_in",
+	"password_confirmation": "Passwort Wiederholung",
+	"confirm_again": "In deinem Postfach solltest du eine Bestätigungsmail finden. Bitte klicke auf den Link darin.",
+	"confirmed": "E-Mail Adresse wurde erfolgreich bestätigt. Danke!",
+	"password_check_inbox": "password_check_inbox",
+	"viewer": "Zuschauer",
+	"editor": "Bearbeiter",
+	"admin": "Admin",
+	"mobile": "Mobil",
+	"image": "Bild",
+	"tool_filter": "Filter",
+	"team": "Team",
+	"search": "Suche",
+	"search_no_results": "Keine Resultate",
+	"search_clear": "Zurücksetzen",
+	"rename": "Umbennen",
+	"login_google": "Mit Google anmelden",
+	"save_changes": "Änderungen speichern",
+	"what_is_your_name": "Willkommen bei %s ! Bitte wähle einen Benutzernamen.",
+	"landing_title": "Dein Online-Whiteboard.",
+	"landing_claim": "Mit Spacedeck kannst du multimedial auf virtuellen Whiteboards im Internet zusammenarbeiten: Kombiniere Texte, Fotos, Websites oder sogar Videos und Sounds. ",
+	"landing_example": "Spacedeck ist ideal, um Ideen zu visualisieren, in kreativen Teams Projekte zu überblicken oder um den Unterricht in Schulen und Universitäten interaktiv zu gestalten.",
+	"spaces": "Meine Spaces",
+	"access_editor_link": "Sofort-Mitmachen-Link",
+	"access_editor_link_desc": "Mit diesem Link kann man sogar ohne Spacedeck-Account sofort mitarbeiten. Praktisch!",
+	"access_editor_link_desc_slug": "Dieser Link beinhaltet den Namen vom Space.",
+	"access_anonymous_edit_blocking": "Anonyme Mitarbeiter dürfen keine Daten anderer anonymer Mitarbeiter ändern.",
+	"access_current_members": "Aktuelle Mitarbeiter",
+	"access_new_members": "Neue Mitarbeiter einladen",
+	"landing_customers": "Tausende Anwender weltweit vertrauen uns.",
+	"landing_features_title": "Schneller zum Ergebnis.",
+	"landing_features_text": "Spacedeck 5 hat eine brandneue Benutzeroberfläche, die das Arbeiten einfacher und intuitiver und macht - gleichzeitig aber auch mehr mächtige Werkzeuge bereitstellt.",
+	"landing_features_1": "<b>Drag & Drop:</b> Bilder-, Video- und Ton-Dateien direkt vom Desktop oder von anderen Webseiten in Spaces ziehen",
+	"landing_features_2": "<b>Textnotizen</b> mit allen Möglichkeiten bei Schriftart, Farbe und Stil",
+	"landing_features_3": "<b>Zeichne und Markiere</b> freihändig oder mit fertigen Formen",
+	"landing_features_4": "Verwandle dein Whiteboard in eine <b>zoombare Präsentation</b>",
+	"landing_features_5": "<b>Arbeite in Echtzeit</b> mit deinen Kollegen, Schülern oder Freunden zusammen",
+	"landing_features_6": "<b>Teile deine Whiteboards</b> per Link oder per E-Mail",
+	"landing_features_7": "<b>Exportiere deine Arbeit</b> als PDF- oder ZIP-Datei",
+	"landing_pricing": "Unfassbar günstig.",
+	"landing_pricing_lite": "Private Nutzung",
+	"landing_pricing_lite_text": "Basisvariante, ausreichend um multimedial zu arbeiten.",
+	"landing_pricing_pro_features_list": "<ul><li>Unbegrenzte Spaces</li><li>Hierarchische Ordnerstruktur</li><li>PDF und ZIP Export</li><li>Keine Wasserzeichen</li><li>Eigene Hintergründe</li><li>Liste von Aktivitäten</li><li>20 GB Speicherplatz</li><ul>",
+	"landing_pricing_pro": "€4,90/Anwender/Mo. <br><small>oder €49,90/Anwender/Jahr</small>",
+	"landing_pricing_pro_text": "Alle Features um professionell zu arbeiten.",
+	"welcome_subject": "Willkommen bei Spacedeck",
+	"welcome_body": "Danke, dass du dich bei Spacedeck angemeldet hast. <br> Wir hoffen, du wirst viel Spaß mit Spacedeck haben. <br> Vergiss nicht, dass du mit unbegrenzt vielen Kollegen und Freunden kostenlos zusammen arbeiten kannst. ",
+	"parent_folder": "Übergeordneter Ordner",
+	"access_no_members": "Noch keine Mitglieder",
+	"invite_emails": "E-Mail Adressen",
+	"created_by": "Erstellt von",
+	"last_updated": "Zuletzt aktualisiert",
+	"comments": "Kommentare",
+	"history_recently_updated": "Aktuelles",
+	"history_recently_empty": "Noch nichts passiert.",
+	"signing_up": "Registierung läuft",
+	"feedback_sent": "Danke für dein Feedback!",
+	"role_member": "Mitglied",
+	"space_message_subject": "Neue Nachricht im Space %s",
+	"space_message_body": "%s schrieb in %s: \n",
+	"password_reset_subject": "Neues Passwort für Spacedeck",
+	"password_reset_body": "Du möchtest das Passwort für deinen Spacedeck Account zurücksetzen?\nBitte klicke dafür auf den folgenden Link:",
+	"password_reset_action": "Jetzt Passwort neu setzen",
+	"pro_ad_history_headline": "Nach einem Upgrade zu Spacedeck Pro kannst du hier einen Überblick über alle aktuellen Aktivitäten in Spaces bekommen.",
+	"was_offline": "Die Verbindung wurde unterbrochen. Wir empfehlen, alle geänderten Objekte erneut zu selektieren, um sie zu speichern.",
+	"subscription_failed_user_subject": "Zahlung fehlgeschlagen",
+	"subscription_failed_user_body": "Unfortunately, we could not process your payment method. You can easly create a new payment method including PayPal in your account settings.",
+	"subscription_failed_team_subject": "Zahlung fehlgeschlagen",
+	"subscription_failed_team_body": "Unfortunately, we could not process your Payment-method for your Team-Account. Please fix your payment method asap.",
+	"add": "hinzufügen",
+	"team_name": "Team-Name",
+	"subdomain": "Subdomain",
+	"invited": "eingeladen",
+	"team_adresses": "E-Mail Adressen",
+	"duplicate_destination": "In welchen Ordner möchtest du den Space duplizieren?",
+	"duplicate_confirm": "%s nach %s duplizieren?",
+	"duplicate_success": "%s wurde in %s dupliziert.",
+	"goto_space": "Gehe zu %s",
+	"goto_folder": "Gehe zu Ordner %s",
+	"stay_here": "Hier bleiben",
+	"sharing": "sharing",
+	"list": "Liste",
+	"download_space": "Space Herunterladen",
+	"duplicate_destination_folder": "Zielordner für Duplikat",
+	"type": "Typ",
+	"promote": "Befördern",
+	"demote": "Zurückstufen",
+	"Previous Zone": "Vorherige Zone",
+	"Next Zone": "Nächste Zone",
+	"profile_slug": "profile_slug",
+	"lock": "sperren",
+	"unlock": "entsperren",
+	"link": "verlinken",
+	"download": "download",
+	"more": "mehr",
+	"follow_present": "Folgen",
+	"mute_present": "Entfolgen",
+  "follow_present_help": "Wenn jemand den Space präsentiert, folgen die anderen Mitglieder automatisch der Präsentation. Mit diesem Knopf lässt sich das an- oder ausschalten."
+}

+ 324 - 0
locales/en.js

@@ -0,0 +1,324 @@
+{
+	"ok": "OK",
+	"cancel": "Cancel",
+	"close": "Close",
+	"open": "Open",
+	"folder": "Folder",
+	"save": "Save",
+	"saved": "Saved",
+	"created": "created",
+	"duplicate": "Duplicate",
+	"delete": "Delete",
+	"remove": "Remove",
+	"set": "set",
+	"reset": "reset",
+	"thanks": "Thanks",
+	"share": "Share",
+	"signup": "Sign Up",
+	"login": "Log in",
+	"logout": "Log out",
+	"email": "Email Address",
+	"password": "Password",
+	"width": "Width",
+	"height": "Height",
+	"nick": "Name",
+	"role": "Role",
+	"members": "Members",
+	"actions": "Actions",
+	"or": "or",
+	"you": "you",
+	"via": "via",
+	"by": "by",
+	"zero": "Zero",
+	"page": "Page",
+	"new": "New",
+	"copy": "Copy",
+	"home": "Home",
+	"owner": "Owner",
+	"space": "Space",
+	"second": "Second",
+	"not_found": "Not Found.",
+	"untitled_space": "Untitled Space",
+	"untitled_folder": "Untitled Folder",
+	"untitled": "untitled",
+	"sure": "Are you sure?",
+	"specify": "Please Specify",
+	"confirm": "Please Confirm",
+	"error_unknown_email": "This email/password combination is unknown.",
+	"error_password_confirmation": "The entered passwords don't match.",
+	"error_domain_blocked": "Your domain is blocked.",
+	"error_user_email_already_used": "This email address is already in use.",
+	"support": "Spacedeck Support",
+	"offline": "Offline. Click for more.",
+	"error": "Sorry, but something went wrong. Please contact support@spacedeck.com",
+	"welcome": "Welcome",
+	"claim": "Your digital Whiteboard.",
+	"trynow": "Try now.",
+	"about": "About us",
+	"terms": "Terms",
+	"contact": "Contact",
+	"privacy": "Privacy",
+	"business_adress": "Business Adress",
+	"post_adress": "Post Adress",
+	"phone": "Phone",
+	"ceo": "Managing Director",
+	"name": "Name",
+	"confirm_subject": "Spacedeck Email Confirmation",
+	"confirm_body": "Thank you for signing up at Spacedeck.\nPlease click on the following link to confirm your email address.\n",
+	"confirm_action": "Confirm Now",
+	"team_invite_membership_subject": "Team Invitation for %s",
+	"team_invite_membership_body": "You have been invited to %s on Spacedeck. Please click on the following link to accept the invitation.",
+	"team_invite_user_body": "You have been invited to %s on Spacedeck.\nYour temporary password is \"%s\".\nPlease click on the following link to accept the invitation.",
+	"team_invite_admin_body": "%s was invited for your team: %s. The temporary password is \"%s\".",
+	"team_invite_membership_acction": "Accept",
+	"team_new_member_subject": "New Team Member for %s signed up",
+	"team_new_member_body": "%s just joined Team %s on Spacedeck.",
+	"space_invite_membership_subject": "%s invited you to a Space %s ",
+	"space_invite_membership_body": "You have been invited by %s to join a Space %s on Spacedeck. Please click on the following link to accept the invitation.",
+	"space_invite_membership_action": "Accept",
+	"folder_invite_membership_subject": "Space",
+	"folder_invite_membership_body": "You have been invited to a Team on Spacedeck. Please click on the following link to accept the invitation.",
+	"folder_invite_membership_acction": "Accept",
+	"login_google": "Login With Google",
+	"save_changes": "Save Changes",
+	"upgrade": "Upgrade",
+	"upgrade_now": "Upgrade Now",
+	"create_space": "Create Space",
+	"create_folder": "Create Folder",
+	"email_unconfirmed": "Email Unconfirmed",
+	"confirmation_sent": "Email Sent",
+	"folder_filter": "Filter",
+	"sort_by": "Sort by",
+	"last_modified": "Last Modified",
+	"last_opened": "Last Opened",
+	"title": "Title",
+	"edit_team": "Edit Team",
+	"edit_account": "Edit Account",
+	"log_out": "Log Out",
+	"no_spaces_yet": "Welcome! You can create Spaces and Folders here using the buttons in the top left corner.",
+	"new_folder_title": "New title for folder",
+	"folder_settings": "Folder Settings",
+	"upload_cover_image": "Upload Cover Image",
+	"spacedeck_pro_ad_folders": "With Spacedeck Pro, you can organize an unlimited amount of Spaces in Folders and manage access controls for each Folder. Would you like to learn more about Pro features?",
+	"spacedeck_pro_ad_versions": "With Spacedeck Pro, you can save unlimited versions of each Space to track your progress or keep snapshots safe. Would you like to learn more about Pro features?",
+	"spacedeck_pro_ad_pdf": "With Spacedeck Pro, you can export your Spaces as crisp PDFs for archiving, mailing around, or printing. Do you want to learn more about Pro features?",
+	"spacedeck_pro_ad_zip": "With Spacedeck Pro, you can export the contents of a Space as a ZIP package. Do you want to learn more about Pro features?",
+	"spacedeck_pro_ad_colors": "With Spacedeck Pro, you can mix your own colors using a professional color picker.",
+	"profile_caption": "Profile",
+	"upload_avatar": "Upload Avatar",
+	"uploading_avatar": "Uploading Avatar…",
+	"avatar_dimensions": "Recommended dimensions: 200×200 pixels.",
+	"profile_name": "Name",
+	"profile_email": "Email Address",
+	"send_again": "Send Again",
+	"confirmation_sent_long": "Email confirmation link sent. Please check your inbox.",
+	"confirmation_sent_another": "Another confirmation link sent.",
+	"confirmation_sent_dialog_text": "We sent you an email explaining how to confirm your email address.",
+	"payment_caption": "Payment",
+	"language_caption": "Language",
+	"notifications_caption": "Notifications",
+	"notifications_option_chat": "Inform me via email about new comments",
+	"notifications_option_spaces": "Send me a daily digest of what happened in my Spaces and Folders",
+	"password_caption": "Password",
+	"current_password": "Current Password",
+	"new_password": "New Password",
+	"verify_password": "Verify Password",
+	"change_password": "Change Password",
+	"reset_password": "Reset Password",
+	"terminate_caption": "Delete Account",
+	"terminate_warning": "If you delete your account, all Spaces, Folders and Messages including all content you and other people created in your Spaces will be destroyed.",
+	"terminate_warning2": "This cannot be undone.",
+	"terminate_reason": "Message",
+	"terminate_reason_caption": "Help us improve by sharing your reasons for cancelling.",
+	"terminate_terminate": "Terminate",
+	"space_blank1": "Welcome to a fresh new Space!",
+	"space_blank2": "Drop files, paste links",
+	"space_blank3": "or use the tools below",
+	"space_blank4": "to fill this Space with content.",
+	"draft": "Draft",
+	"publish": "Publish",
+	"published": "Published",
+	"save_version": "Save Version",
+	"version_saved": "Version Saved",
+	"post": "Post Message",
+	"chat_invite_cta1": "Collaboration is fun!",
+	"chat_invite_cta2": "Why not ",
+	"chat_invite_cta3": "invite some people",
+	"chat_invite_cta4": "to work with you?",
+	"chat_message_placeholder": "Write your message…",
+	"view": "View",
+	"edit": "Edit",
+	"present": "Present",
+	"chat": "Chat",
+	"meta": "Meta",
+	"tool_search": "Search",
+	"tool_upload": "Upload",
+	"tool_text": "Text",
+	"tool_shape": "Shape",
+	"tool_zones": "Zones",
+	"tool_canvas": "Canvas",
+	"search_media": "Search media…",
+	"type_here": "Type here",
+	"text_formats": "Formats",
+	"format_p": "Paragraph",
+	"format_bullets": "Bullet List",
+	"format_numbers": "Numbered List",
+	"format_h1": "Headline 1",
+	"format_h2": "Headline 2",
+	"format_h3": "Headline 3",
+	"font_size": "Font Size",
+	"line_height": "Line Height",
+	"tool_align": "Align",
+	"tool_styles": "Styles",
+	"tool_bullets": "Bullets",
+	"tool_numbers": "Numbers",
+	"color_fill": "Fill",
+	"color_stroke": "Stroke",
+	"color_text": "Text",
+	"tool_type": "Type",
+	"tool_box": "Box",
+	"tool_link": "Link",
+	"tool_layout": "Layout",
+	"tool_options": "Options",
+	"tool_stroke": "Stroke",
+	"tool_delete": "Delete",
+	"tool_lock": "Lock",
+	"tool_copy": "Copy",
+	"stack": "Stack",
+	"tool_circle": "Circle",
+	"tool_hexagon": "Hexagon",
+	"tool_square": "Square",
+	"tool_diamond": "Diamond",
+	"tool_bubble": "Bubble",
+	"tool_cloud": "Cloud",
+	"tool_burst": "Burst",
+	"tool_star": "Star",
+	"tool_heart": "Heart",
+	"tool_scribble": "Scribble",
+	"tool_line": "Line",
+	"tool_arrow": "Arrow",
+	"search_media_placeholder": "Search web media…",
+	"add_zone": "New Zone",
+	"palette": "Palette",
+	"picker": "Picker",
+	"background_image_caption": "Image",
+	"background_color_caption": "Color",
+	"upload_background_caption": "Click to upload a background image",
+	"upload_background": "Upload Background",
+	"access_caption": "Access",
+	"versions_caption": "Versions",
+	"info_caption": "Info",
+	"mode_private": "Private: Only members can view or edit",
+	"mode_public": "Public: Anyone with the link can view",
+	"invite_collaborators": "Invite Collaborators",
+	"revoke_access": "Revoke Access",
+	"invite": "Send Invitations",
+	"invitee_email_address": "Email address of new member",
+	"optional_message": "Optional message",
+	"role_viewer": "Viewer",
+	"role_editor": "Editor",
+	"role_admin": "Admin",
+	"new_space_title": "New title for Space",
+	"team": "Team",
+	"search": "Search",
+	"search_no_results": "search_no_results",
+	"search_clear": "search_clear",
+	"rename": "Rename",
+	"mobile": "mobile",
+	"image": "image",
+	"tool_filter": "filter",
+	"canel": "canel",
+	"invite_membership_action": "invite_membership_action",
+	"viewer": "viewer",
+	"editor": "editor",
+	"admin": "admin",
+	"logging_in": "logging in",
+	"password_confirmation": "Password Confirmation",
+	"confirm_again": "We sent you an email explaining how to confirm your email address.",
+	"confirmed": "Your Account was confirmed successfully. Thank you.",
+	"signing_up": "Signing up",
+	"password_check_inbox": "Please check your inbox",
+	"new_space": "New Space",
+	"tool_more": "More",
+	"what_is_your_name": "Welcome to %s! Please choose a username.",
+	"lang": "en",
+	"landing_title": "Your Whiteboard on the Web.",
+	"landing_claim": "Spacedeck lets you easily combine all kinds of media on virtual whiteboards: Text notes, photos, web links, even videos and audio recordings. ",
+	"landing_example": "People use Spacedeck to organize their ideas, in teams to see whole projects at a glance, or in schools and universities for richer, connected learning experiences.",
+	"spaces": "My Spaces",
+	"access_editor_link": "Instant Edit Link",
+	"access_editor_link_desc": "Give this link to anyone who should be able to instantly edit this Space, no account required: ",
+	"access_editor_link_desc_slug": "This link contains the name of the space, too. ",
+	"access_anonymous_edit_blocking": "Anonymous Editors may only change their own items",
+	"access_current_members": "Current Members",
+	"access_new_members": "Invite New Members",
+	"access_no_members": "Members of this Space will show up here.",
+	"comments": "comments",
+	"landing_customers": "Trusted by Thousands.",
+	"landing_features_title": "A Breeze To Use.",
+	"landing_features_text": "The new Spacedeck 5 has a streamlined, beautiful user interface that makes your work easier and more fun than ever before – while giving you even more powerful features:",
+	"landing_features_1": "<b>Drag & drop</b> images, videos and audio from your computer or the web",
+	"landing_features_2": "<b>Write and format tex</b>t with full control over fonts, colors and style",
+	"landing_features_3": "<b>Draw, annotate and highlight</b> with included graphical shapes",
+	"landing_features_4": "Turn your board into a <b>zooming presentation</b>",
+	"landing_features_5": "<b>Collaborate and chat</b> in realtime with teammates, students or friends.",
+	"landing_features_6": "<b>Share Spaces</b> on the web or via email",
+	"landing_features_7": "<b>Export your work</b> as printable PDF or ZIP",
+	"landing_pricing": "Incredibly Affordable.",
+	"landing_pricing_lite": "Free/Personal Use",
+	"landing_pricing_lite_text": "The basic, well-rounded version for collecting pictures and keeping notes.",
+	"landing_pricing_pro_features_list": "<ul><li>Unlimited Spaces</li><li>Folder Structures</li><li>PDF and ZIP Export</li><li>No Watermarks</li><li>Custom Backgrounds</li><li>Activity History</li><li>20 GB Storage</li><ul>",
+	"landing_pricing_pro": "€4,90/User/Mo. <br><small>or 49,90/User/Year</small>",
+	"landing_pricing_pro_text": "Turbocharged with all the power you expect.",
+	"landing_pricing_pro_features": "Turbocharged with all the power you expect.",
+	"welcome_subject": "Welcome to Spacedeck",
+	"welcome_body": "Hello!\nThank you for signing up at Spacedeck.<br>We hope you will enjoy working in Spaces.<br>Remember, your account includes unlimited collaborators. Feel free to share your Spaces with friends and colleagues all over the world.",
+	"invite_emails": "Email addresses (Comma separated)",
+	"history_recently_updated": "Recently Updated",
+	"history_recently_empty": "Nothing has happened yet.",
+	"parent_folder": "parent_folder",
+	"created_by": "Created by",
+	"last_updated": "Last updated",
+	"feedback_sent": "Thanks for your feedback!",
+	"role_member": "Member",
+	"team_invite_membership_action": "Accept invitation",
+	"space_message_subject": "New Message in Space %s",
+	"space_message_body": "%s wrote in %s: \n",
+	"pro_ad_history_headline": "When you upgrade to Spacedeck Pro, you will see a history of recent updates across all your (shared) Spaces here.",
+	"password_reset_subject": "Reset Password for Spacedeck",
+	"password_reset_body": "You requested a reset of your Spacedeck password.\nPlease click on the following link to set a new password.",
+	"password_reset_action": "Reset Now",
+	"was_offline": "The connection to Spacedeck was interrupted. If you have unsaved work, please keep this browser tab open until the connection is re-established, then touch the unsaved objects again.",
+	"subscription_failed_user_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_user_body": "Unfortunately, we could not process your Payment-method. You can easly create a new payment method including PayPal in your account settings.",
+	"subscription_failed_team_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_team_body": "Unfortunately, we could not process your Payment-method for your Team-Account. Please fix your payment method asap.",
+	"team_name": "Team Name",
+	"subdomain": "Subdomain",
+	"team_adresses": "Email adresses",
+	"add": "Add",
+	"invited": "invited",
+	"duplicate_destination": "Into which folder do you want to duplicate this Space?",
+	"duplicate_confirm": "Duplicate %s into %s?",
+	"duplicate_success": "%s was duplicated into %s.",
+	"goto_space": "Go to Space %s",
+	"goto_folder": "Go to Folder %s",
+	"stay_here": "Stay here",
+	"sharing": "Sharing",
+	"list": "Export List",
+	"link": "Link",
+	"download_space": "Download Space",
+	"type": "Type",
+	"download": "Download",
+	"Previous Zone": "Previous Zone",
+	"Next Zone": "Next Zone",
+	"promote": "Promote",
+	"demote": "Demote",
+	"more": "More",
+	"lock": "Lock",
+	"unlock": "Unlock",
+	"follow_present": "Follow",
+	"mute_present": "Unfollow",
+	"follow_present_help": "If someone else is presenting this Space, the other members automatically follow the presentation. Switch following on or off with this button.",
+	"export": "export"
+}

+ 318 - 0
locales/fr.js

@@ -0,0 +1,318 @@
+{
+	"lang": "fr",
+	"ok": "OK",
+	"cancel": "Annuler",
+	"close": "Fermer",
+	"open": "Ouvrir",
+	"folder": "Dossier",
+	"save": "Enregistrer",
+	"saved": "Enregistrée",
+	"created": "établi",
+	"duplicate": "Dupliquer",
+	"delete": "Supprimer",
+	"remove": "Enlever",
+	"set": "Définir",
+	"reset": "Rédéfinir",
+	"thanks": "Merci",
+	"share": "Partager",
+	"signup": "S'inscrire",
+	"login": "Se connecter",
+	"logout": "Se déconnecter",
+	"email": "Adresse email",
+	"password": "Mot de passe",
+	"width": "Largeur",
+	"height": "Hauteur",
+	"nick": "Nom",
+	"role": "Rôle",
+	"members": "Membres",
+	"actions": "Actions",
+	"or": "ou",
+	"you": "vous",
+	"via": "par",
+	"by": "par",
+	"zero": "Zero",
+	"page": "Page",
+	"new": "Neuf",
+	"copy": "Copier",
+	"owner": "Possesseur",
+	"home": "Accueil",
+	"space": "Espace",
+	"second": "Seconde",
+	"not_found": "Pas trouvé.",
+	"untitled": "sans titre",
+	"untitled_space": "Espace sans titre",
+	"untitled_folder": "Dossier sans titre",
+	"sure": "Êtes-vous sûr?",
+	"specify": "Veuillez préciser:",
+	"confirm": "Veuillez confirmer",
+	"signup_google": "S'inscrire avec Google",
+	"error_unknown_email": "Combinaison inconnue de l'email et mot de passe.",
+	"error_password_confirmation": "Les deux mots de passe ne correspondent pas.",
+	"error_domain_blocked": "Ce domaine a été désactivé.",
+	"error_user_email_already_used": "Cette adresse email est déjà enregistré.",
+	"support": "Aide Spacedeck",
+	"offline": "Désolé , mais les serveurs Spacedeck ne peuvent pas être atteint pour le moment. Plus d' informations ici.",
+	"error": "Désolé, une erreur s'est produite. Veuillez contacter support@spacedeck.com",
+	"welcome": "Bienvenue",
+	"claim": "Le tableau blanc partagé pour tout le monde",
+	"trynow": "Essayez-le gratuitement",
+	"about": "de nous",
+	"terms": "termes",
+	"contact": "contact",
+	"privacy": "sphère privée",
+	"business_adress": "Siège social",
+	"post_adress": "Adresse courrier",
+	"phone": "téléphone",
+	"ceo": "Gestionnaire",
+	"name": "name",
+	"confirm_subject": "Confirmation de l'email Spacedeck",
+	"confirm_body": "Merci pour votre inscription à Spacedeck.\nSil vous plaît cliquez sur le lien suivant pour confirmer votre adresse e-mail.",
+	"confirm_action": "Confirmer",
+	"team_invite_membership_subject": "Team Invitation for %s",
+	"team_invite_membership_body": "You have been invited to %s on Spacedeck.\nPlease click on the following link to accept the invitation.",
+	"team_invite_user_body": "You have been invited to %s on Spacedeck.\nYour temporary password is \"%s\".\nPlease click on the following link to accept the invitation.",
+	"team_invite_admin_body": "%s was invited for your team: %s. The temporary password is \"%s\".",
+	"team_invite_membership_acction": "Accept",
+	"team_new_member_subject": "New Team Member",
+	"team_new_member_body": "%s just joined Team %s on Spacedeck.",
+	"invite_emails": "Entrer les adresses email (séparées pas des virgules)",
+	"optional_message": "Message personnel (facultatif)",
+	"space_invite_membership_subject": "Invitation Espace par %s: %s",
+	"space_invite_membership_body": "Vous avez été invité par %s à Espace \"%s\"",
+	"space_invite_membership_action": "Accepter L'invitation",
+	"folder_invite_membership_subject": "Space",
+	"folder_invite_membership_body": "You have been invited to a Team on Spacedeck. Please click on the following link to accept the invitation.",
+	"folder_invite_membership_acction": "Accept",
+	"login_google": "S'inscrire avec Google",
+	"save_changes": "Enregistrer",
+	"upgrade": "Upgrade",
+	"upgrade_now": "Mise à niveau",
+	"create_space": "Créer un espace",
+	"create_folder": "Créer un dossier",
+	"email_unconfirmed": "Email non confirmée",
+	"confirmation_sent": "L'email est envoyé.",
+	"folder_filter": "Filtre",
+	"sort_by": "Ordre",
+	"last_modified": "Dernière modification",
+	"last_opened": "Dernière ouverture",
+	"title": "Titre",
+	"edit_team": "Modifier l'équipe",
+	"edit_account": "Modifier le compte",
+	"log_out": "Déconnecter",
+	"no_spaces_yet": "Vous ne avez pas encore créé d'espaces.",
+	"new_folder_title": "Nouveau titre pour le dossier",
+	"folder_settings": "Paramètres du dossier",
+	"upload_cover_image": "Charger image de couverture",
+	"spacedeck_pro_ad_folders": "Avec Spacedeck Pro, vous pouvez organiser un nombre illimité de espaces dans les dossiers et gérer les contrôles d'accès pour chaque dossier. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_versions": "Avec Spacedeck Pro, vous pouvez enregistrer des versions illimitées de chaque espace pour suivre vos progrès ou de conserver des instantanés sécurité. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_pdf": "Avec Spacedeck Pro, vous pouvez exporter vos espaces et même des dossiers entiers belles PDF pour l'archivage, de diffusion, ou autour de l'impression. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_zip": "Avec Spacedeck Pro, vous pouvez exporter le contenu d'un espace comme un paquet ZIP. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_colors": "Avec Spacedeck Pro, vous pouvez mélanger vos propres couleurs en utilisant un sélecteur de couleur professionnelle.",
+	"profile_caption": "Profil",
+	"upload_avatar": "Télécharger l'image profil",
+	"uploading_avatar": "L'image de profil est téléchargée…",
+	"avatar_dimensions": "Format suggéré: 200×200 pixels.",
+	"profile_name": "Name",
+	"profile_email": "Email",
+	"send_again": "Renvoyer",
+	"confirmation_sent_long": "Lien de confirmation email envoyé. Se il vous plaît vérifier votre courrier.",
+	"confirmation_sent_another": "Nous avons envoyé un autre lien de confirmation.",
+	"confirmation_sent_dialog_text": "Nous vous avons envoyé un email expliquant comment confirmer votre adresse email.",
+	"payment_caption": "Paiement",
+	"language_caption": "Langue",
+	"notifications_caption": "Emails",
+	"notifications_option_chat": "Envoyez-moi les nouveaux commentaires par email.",
+	"notifications_option_spaces": "Envoyez-moi un résumé quotidien des modifications à mes espaces.",
+	"password_caption": "Mot de passe",
+	"current_password": "Ancien mot de passe",
+	"new_password": "Nouveau mot de passe",
+	"verify_password": "Répéter mot de passe",
+	"change_password": "Enregistrer",
+	"reset_password": "Mot de passe oublié?",
+	"terminate_caption": "Supprimer le compte",
+	"terminate_warning": "En supprimant votre compte, vos messages, espaces, dossiers et tout leur contenu seront effacés. Cette action ne peut être annulée.",
+	"terminate_warning2": "Cela ne peut pas être annulée.",
+	"terminate_reason": "Problèmes rencontrés",
+	"terminate_reason_caption": "Aidez-nous à améliorer le produit en précisant les raisons de la suppression de votre compte.",
+	"terminate_terminate": "Supprimer le compte définitivement?",
+	"space_blank1": "Ceci est votre nouvel espace.",
+	"space_blank2": "Déposez des fichiers, collez des liens web",
+	"space_blank3": "ou utilisez les outils.",
+	"space_blank4": "Soyez créatifs!",
+	"draft": "Conception",
+	"publish": "Publier",
+	"published": "Publié",
+	"save_version": "Enregistrer une version",
+	"version_saved": "Version enregistrée.",
+	"post": "Envoyer",
+	"chat_invite_cta1": "Travailler ensemble est amusant!",
+	"chat_invite_cta2": "Pourquoi ",
+	"chat_invite_cta3": "ne pas vous invitez quelques collaborateurs?",
+	"chat_invite_cta4": "",
+	"chat_message_placeholder": "Votre message ici…",
+	"view": "Vue",
+	"edit": "Éditer",
+	"present": "Prés.",
+	"chat": "Chat",
+	"meta": "Meta",
+	"tool_search": "Chercher",
+	"tool_upload": "Charger",
+	"tool_text": "Texte",
+	"tool_shape": "Dessin",
+	"tool_zones": "Zones",
+	"tool_canvas": "Mur",
+	"search_media": "Chercher le web pour les médias…",
+	"type_here": "Entrez quelque chose ici",
+	"text_formats": "Formats",
+	"format_p": "Paragraphe",
+	"format_bullets": "Liste à puces",
+	"format_numbers": "Liste numérotée",
+	"format_h1": "Titre 1",
+	"format_h2": "Titre 2",
+	"format_h3": "Titre 3",
+	"font_size": "Taille de la police",
+	"line_height": "Hauteur de ligne",
+	"tool_align": "Align",
+	"tool_styles": "Style",
+	"tool_bullets": "Puces",
+	"tool_numbers": "Numéros",
+	"color_fill": "Fond",
+	"color_stroke": "Ligne",
+	"color_text": "Text",
+	"tool_type": "Typo.",
+	"tool_box": "Box",
+	"tool_link": "Lien",
+	"tool_layout": "Layout",
+	"tool_options": "Plus",
+	"tool_stroke": "Ligne",
+	"tool_delete": "Effacer",
+	"tool_lock": "Bloquer",
+	"tool_copy": "Copie",
+	"stack": "Empiler",
+	"tool_circle": "Cercle",
+	"tool_hexagon": "Hexagone",
+	"tool_square": "Carré",
+	"tool_diamond": "Diamant",
+	"tool_bubble": "Bulle",
+	"tool_cloud": "Nuage",
+	"tool_burst": "Éclat",
+	"tool_star": "Étoile",
+	"tool_heart": "Cœur",
+	"tool_scribble": "Crayon",
+	"tool_line": "Ligne",
+	"tool_arrow": "Flèche",
+	"search_media_placeholder": "Chercher le web pour les médias…",
+	"add_zone": "Ajouter Zone",
+	"palette": "Palette",
+	"picker": "Mélange",
+	"background_image_caption": "Image",
+	"background_color_caption": "Couleur",
+	"upload_background_caption": "Cliquez ici pour télécharger une image de fond.",
+	"upload_background": "Télécharger",
+	"access_caption": "Accès",
+	"versions_caption": "Versions",
+	"info_caption": "Info",
+	"mode_private": "Privé",
+	"mode_public": "Public",
+	"invite_collaborators": "Inviter les collaborateurs",
+	"revoke_access": "Révoquer l'accès",
+	"invite": "Inviter",
+	"role_viewer": "Spectateur",
+	"role_editor": "Éditeur",
+	"role_admin": "Administrateur",
+	"new_space_title": "Nouveau titre pour l'espace",
+	"invitee_email_address": "Adresse e-mail de invitee",
+	"viewer": "Spectateur",
+	"editor": "Éditeur",
+	"admin": "Administrateur",
+	"mobile": "Mobile",
+	"image": "Image",
+	"tool_filter": "Filter",
+	"team": "Team",
+	"search": "Recherche",
+	"search_no_results": "Aucun résultat trouvé",
+	"search_clear": "Supprimer",
+	"rename": "Renommer",
+	"logging_in": "Connexion",
+	"password_confirmation": "Confirmation du mot de passe",
+	"confirm_again": "Veuillez consulter votre boîte pour confirmer votre email.",
+	"confirmed": "Adresse email confirmée avec succès. merci!",
+	"password_check_inbox": "password_check_inbox",
+	"what_is_your_name": "Bonjour! Choisir un nom d'utilisateur s'il vous plaît.",
+	"landing_title": "Le tableau blanc partagé pour tout le monde.",
+	"landing_claim": "Le tableau blanc partagé pour tout le monde.",
+	"landing_example": "Que vous soyez étudiant, enseignant ou chercheur: Avec Spacedeck il est facile pour vous de créer, de gérer et de partager des cours ou le travail en classe. Développez vos théories visuellement. Organisez des notes de recherche, web, images, audio et vidéo.",
+	"spaces": "Espaces",
+	"access_editor_link": "Lien instantané.",
+	"access_editor_link_desc": "Donnez ce lien à tous ceux que vous voulez inviter rapidement. Ils n’ont pas besoin créer un compte Spacedeck.",
+	"access_editor_link_desc_slug": "Y compris le nom du lien",
+	"access_anonymous_edit_blocking": "Ces invités ne peuvent modifier que les éléments qu’ils ont eux-même créé.",
+	"access_current_members": "Membres actuels",
+	"comments": "Commentaires",
+	"access_no_members": "No members yet. You can invite some below.",
+	"access_new_members": "Inviter de nouveaux membres.",
+	"landing_customers": "Approuvé par des milliers.",
+	"landing_features_title": "Un jeu d'enfant.",
+	"landing_features_text": "Le tout nouveau Spacedeck 5 vous permet de travailler bien plus facilement grâce à sa magnifique interface simplifiée.",
+	"landing_features_1": "Glissez & déposez images, vidéos et audios de votre ordinateur ou du web",
+	"landing_features_2": "Ecrivez directement sur l'espace et choisissez les polices de caractère, couleurs et styles",
+	"landing_features_3": "Dessinez, annotez et surlignez grâce aux formes graphiques intégrées",
+	"landing_features_4": "Transformez votre espace en une présentation dynamique",
+	"landing_features_5": "Collaborez et discutez en temps réel avec vos collègues, élèves et amis",
+	"landing_features_6": "Partagez vos espaces sur le web ou par email",
+	"landing_features_7": "Exportez votre espace en PDF pour l'imprimer",
+	"landing_pricing": "Incroyablement abordable.",
+	"landing_pricing_lite": "Usage personnel",
+	"landing_pricing_lite_text": "La version de base, bien arrondi pour recueillir des images et de garder des notes.",
+	"landing_pricing_pro_features_list": "<ul><li>Unlimited Spaces</li><li>Exporter PDF, ZIP</li><li>No Watermarks</li><li>Image de fonds</li><li>Activity History</li><li>20 Go de stockage</li><ul>",
+	"landing_pricing_pro": "€4,90/User/Mo. <br><small> €49,90/User/Year</small>",
+	"landing_pricing_pro_text": "Avec toute la puissance que vous attendez.",
+	"landing_pricing_pro_features": "Avec toute la puissance que vous attendez.",
+	"welcome_subject": "Bienvenue sur Spacedeck",
+	"welcome_body": "Merci pour votre inscription à Spacedeck.\nNous espérons que vous aurez plaisir à travailler dans les Espaces. <br> Rappelez-vous que votre compte comprend un nombre illimité de collaborateurs. <br> N''hésitez pas à partager vos espaces avec des amis et collègues du monde entier.",
+	"parent_folder": "Dossier origine",
+	"created_by": "Créé par",
+	"last_updated": "Mis à jour",
+	"history_recently_updated": "Nouvelles",
+	"history_recently_empty": "Rien ne se passe",
+	"signing_up": "Signing Up",
+	"feedback_sent": "Merci pour votre commentaire!",
+	"space_message_subject": "A posté sur %s",
+	"space_message_body": "%s a commenté dans %s:\n",
+	"role_member": "role_member",
+	"password_reset_subject": "Réinitialiser le Mot de passe pour Spacedeck",
+	"password_reset_body": "Salut!<br><br>Vous avez demandé la réinitialisation de votre Mot de passe.<br>Veuillez cliquer sur le lien suivant pour définir un nouveau Mot de passe.<br>",
+	"password_reset_action": "Définir un nouveau Mot de passe",
+	"was_offline": "The connection to Spacedeck was interrupted. If you have unsaved work, please keep this browser tab open until the connection is re-established, then touch the unsaved objects again.",
+	"subscription_failed_user_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_user_body": "Unfortunately, we could not process your Payment-method. You can easly create a new payment method including PayPal in your account settings.",
+	"subscription_failed_team_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_team_body": "Unfortunately, we could not process your Payment-method for your Team-Account. Please fix your payment method asap.",
+	"pro_ad_history_headline": "Après une mise à niveau vous pouvez obtenir un aperçu de toutes les activités actuelles dans les espaces ici.",
+	"add": "ajouter",
+	"team_name": "Nom de l'équipe",
+	"subdomain": "sous-domaine",
+	"invited": "invité",
+	"team_adresses": "E-mail adresse",
+	"duplicate_destination": "Sélectionnez le dossier de destination",
+	"duplicate_confirm": "Dupliquer %s dans %s?",
+	"duplicate_success": "%s a été dupliqué dans %s.",
+	"goto_space": "Aller à l'espace %s",
+	"goto_folder": "Aller au dossier %s",
+	"stay_here": "Reste ici",
+	"download_space": "télécharger un espace",
+	"type": "Type",
+	"Previous Zone": "Zone précédent",
+	"Next Zone": "Zone suivante",
+	"list": "liste",
+	"promote": "promouvoir",
+	"demote": "rétrograder",
+	"lock": "bloquer",
+	"unlock": "déverrouillage",
+	"link": "link",
+	"download": "download",
+	"more": "plus",
+	"follow_present": "Suivre",
+	"mute_present": "Pas suivre",
+	"follow_present_help": "follow_present_help"
+}

+ 19 - 0
middlewares/404.js

@@ -0,0 +1,19 @@
+'use strict';
+
+require('../models/db');
+var config = require('config');
+
+module.exports = (req, res, next) => {
+  var err = new Error('Not Found');
+  if (req.accepts('text/html')) {
+    res.status(404).render('not_found', {
+      title: 'Page Not Found.'
+    });
+  } else if (req.accepts('application/json')) {
+    res.status(404).json({
+      "error": "not_found"
+    });
+  } else {
+    res.status(404).send("Not Found.");
+  }
+}

+ 10 - 0
middlewares/500.js

@@ -0,0 +1,10 @@
+'use strict';
+
+module.exports = (err, req, res, next) => {
+  console.error(err);
+  res.status(err.status || 500);
+  res.render('error', {
+    message: err.message,
+    error: {}
+  });
+}

+ 57 - 0
middlewares/api_helpers.js

@@ -0,0 +1,57 @@
+'use strict';
+
+require('../models/db');
+var config = require('config');
+const redis = require('../helpers/redis');
+
+// FIXME TODO object.toJSON()
+
+var saveAction = (actionKey, object) => {
+  if (object.constructor.modelName == "Space")
+    return;
+
+  let attr = {
+    action: actionKey,
+    space: object.space_id || object.space,
+    user: object.user_id || object.user,
+    editor_name: object.editor_name,
+    object: object
+  };
+
+  /*let action = new Action(attr);
+  action.save(function(err) {
+    if (err)
+      console.error("saved create action err:", err);
+  });*/
+};
+
+module.exports = (req, res, next) => {
+  res.header("Cache-Control", "no-cache");
+
+  req['channelId'] = req.headers['x-spacedeck-channel'];
+  req['spacePassword'] = req.headers['x-spacedeck-spacepassword'];
+  req['spaceAuth'] = req.query['spaceAuth'] || req.headers['x-spacedeck-space-auth'];
+
+  res['distributeCreate'] = function(model, object) {
+    if (!object) return;
+    redis.sendMessage("create", model, object, req.channelId);
+    this.status(201).json(object);
+    saveAction("create", object);
+  };
+
+  res['distributeUpdate'] = function(model, object) {
+    if (!object) return;
+    redis.sendMessage("update", model, object, req.channelId);
+    this.status(200).json(object);
+    saveAction("update", object);
+  };
+
+  res['distributeDelete'] = function(model, object) {
+    if (!object) return;
+    redis.sendMessage("delete", model, object, req.channelId);
+    this.sendStatus(204);
+    saveAction("delete", object);
+  };
+
+  next();
+}

+ 20 - 0
middlewares/artifact_helpers.js

@@ -0,0 +1,20 @@
+'use strict';
+const db = require('../models/db');
+const Sequelize = require('sequelize');
+const Op = Sequelize.Op;
+
+var config = require('config');
+
+module.exports = (req, res, next) => {
+  var artifactId = req.params.artifact_id;
+  db.Artifact.findOne({where: {
+    "_id": artifactId
+  }}).then(artifact => {
+    if (artifact) {
+      req['artifact'] = artifact;
+      next();
+    } else {
+      res.sendStatus(404);
+    }
+  });
+};

+ 48 - 0
middlewares/cors.js

@@ -0,0 +1,48 @@
+'use strict';
+
+require('../models/db');
+const config = require('config');
+const url = require('url');
+
+function respond(origin, req, res, next) {
+  res.header('Access-Control-Allow-Origin', origin);
+  res.header('Access-Control-Allow-Credentials', true);
+  res.header('Access-Control-Max-Age', 60 * 60 * 24);
+  res.header('Access-Control-Expose-Headers', 'Accepts, Content-Type, X-Spacedeck-Space-Role, X-Spacedeck-Channel, X-Spacedeck-Spacepassword, X-Spacedeck-Auth, X-Spacedeck-Space-Auth');
+  res.header('Access-Control-Allow-Headers', 'Accepts, Accept-Language, Accept-Encoding, Accept-Language, Content-Type, X-Spacedeck-Space-Auth, X-Spacedeck-Space-Role, X-Spacedeck-Channel, X-Spacedeck-Spacepassword, X-Spacedeck-Auth');
+  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
+
+  if (req.method == 'OPTIONS') {
+    res.sendStatus(204);
+  } else {
+    next();
+  }
+}
+
+module.exports = (req, res, next) => {
+  const origin = req.headers.origin;
+
+  if (origin) {
+    const parsedUrl = url.parse(origin, true, true);
+
+    // FIXME
+    if (parsedUrl.hostname == "cdn.spacedeck.com") {
+      res.header('Cache-Control', "max-age");
+      res.header('Expires', "30d");
+      res.removeHeader("Pragma");
+
+      respond(origin, req, res, next);
+    } else {
+      //Team.getTeamForHost(parsedUrl.hostname, (err, team, subdomain) => {
+        //if (team) {
+          respond(origin, req, res, next);
+        //} else {
+          next();
+        //}
+      //});
+    }
+
+  } else {
+    next();
+  }
+}

+ 16 - 0
middlewares/error_helpers.js

@@ -0,0 +1,16 @@
+'use strict';
+
+module.exports = (req, res, next) => {
+  res.bad_request = (msg) => {
+    if (req.accepts('text/html')) {
+      res.status(400).render('error', {
+        message: msg
+      });
+    } else {
+      res.status(400).json({
+        "error": msg
+      });
+    }
+  }
+  next();
+}

+ 17 - 0
middlewares/i18n.js

@@ -0,0 +1,17 @@
+'use strict';
+
+require('../models/db');
+var config = require('config');
+
+module.exports = (req, res, next) => {
+  req.i18n.setLocale(req.i18n.prefLocale);
+
+  if (req.cookies.spacedeck_locale) {
+    req.i18n.setLocaleFromCookie();
+  }
+
+  if (req.user && req.user.prefs_language) {
+    req.i18n.setLocale(req.user.prefs_language);
+  }
+  next();
+}

+ 47 - 0
middlewares/session.js

@@ -0,0 +1,47 @@
+'use strict';
+
+const db = require('../models/db');
+var config = require('config');
+
+module.exports = (req, res, next) => {
+  const token = req.cookies["sdsession"];
+  
+  if (token && token != "null" && token != null) {
+    db.Session.findOne({where: {token: token}})
+      .then(session => {
+        if (!session) {
+          // session not found
+          next();
+        }
+        else db.User.findOne({where: {_id: session.user_id}})
+          .then(user => {
+            if (!user) {
+              var domain = (process.env.NODE_ENV == "production") ? new URL(config.get('endpoint')).hostname : req.headers.hostname;
+              res.clearCookie('sdsession', { domain: domain });
+
+              if (req.accepts("text/html")) {
+                res.send("Please clear your cookies and try again.");
+              } else if (req.accepts('application/json')) {
+                res.status(403).json({
+                  "error": "token_not_found"
+                });
+              } else {
+                res.send("Please clear your cookies and try again.");
+              }
+              
+            } else {
+              req["token"] = token;
+              req["user"] = user;
+              next();
+            }
+          });
+      })
+      .error(err => {
+        console.error("Session resolve error",err);
+        next();
+      });
+  } else {
+    next();
+  }
+}
+

+ 118 - 0
middlewares/space_helpers.js

@@ -0,0 +1,118 @@
+'use strict';
+
+const db = require('../models/db');
+var config = require('config');
+
+module.exports = (req, res, next) => {
+  let spaceId = req.params.id;
+
+  let finalizeReq = (space, role) => {
+    if (role === "none") {
+      res.status(403).json({
+        "error": "access denied"
+      });
+    } else {
+      req['space'] = space;
+      req['spaceRole'] = role;
+      res.header("x-spacedeck-space-role", req['spaceRole']);
+      next();
+    }
+  };
+
+  var finalizeAnonymousLogin = function(space, spaceAuth) {
+    var role = "none";
+
+    if (spaceAuth && (spaceAuth === space.edit_hash)) {
+      role = "editor";
+    } else {
+      if (space.access_mode == "public") {
+        role = "viewer";
+      } else {
+        role = "none";
+      }
+    }
+
+    if (req.user) {
+      db.getUserRoleInSpace(space, req.user, function(newRole) {
+        if (newRole == "admin" && (role == "editor" || role == "viewer")) {
+          finalizeReq(space, newRole);
+        } else if (newRole == "editor" && (role == "viewer")) {
+          finalizeReq(space, newRole);
+        } else {
+          finalizeReq(space, role);
+        }
+      });
+    } else {
+      finalizeReq(space, role);
+    }
+  };
+
+  var userMapping = {
+    '_id': 1,
+    'nickname': 1,
+    'email': 1
+  };
+
+  db.Space.findOne({where: {
+    "_id": spaceId
+  }}).then(function(space) {
+
+    //.populate("creator", userMapping)
+    //if (err) {
+    //  res.status(400).json(err);
+    //} else {
+
+    if (space) {
+      if (space.access_mode == "public") {
+        if (space.password) {
+          if (req.spacePassword) {
+            if (req.spacePassword === space.password) {
+              finalizeAnonymousLogin(space, req["spaceAuth"]);
+            } else {
+              res.status(403).json({
+                "error": "password_wrong"
+              });
+            }
+          } else {
+            res.status(401).json({
+              "error": "password_required"
+            });
+          }
+        } else {
+          finalizeAnonymousLogin(space, req["spaceAuth"]);
+        }
+
+      } else {
+        // space is private
+        
+        // special permission for screenshot/pdf export from backend
+        if (req.query['api_token'] && req.query['api_token'] == config.get('phantom_api_secret')) {
+          finalizeReq(space, "viewer");
+          return;
+        }
+
+        if (req.user) {
+          db.getUserRoleInSpace(space, req.user, function(role) {
+            if (role == "none") {
+              finalizeAnonymousLogin(space, req["spaceAuth"]);
+            } else {
+              finalizeReq(space, role);
+            }
+          });
+        } else {
+          if (req.spaceAuth && space.edit_hash) {
+            finalizeAnonymousLogin(space, req["spaceAuth"]);
+          } else {
+            res.status(403).json({
+              "error": "auth_required"
+            });
+          }
+        }
+      }
+    } else {
+      res.status(404).json({
+        "error": "space_not_found"
+      });
+    }
+  });
+}

+ 34 - 0
models/action.js

@@ -0,0 +1,34 @@
+'use strict';
+
+// FIXME port this last model
+
+var mongoose = require('mongoose');
+var Schema = mongoose.Schema;
+
+module.exports.actionSchema = mongoose.Schema({
+  space: {
+    type: Schema.Types.ObjectId,
+    ref: 'Space'
+  },
+  user: {
+    type: Schema.Types.ObjectId,
+    ref: 'User'
+  },
+  editor_name: String,
+  action: String,
+  object: Schema.Types.Mixed,
+  created_at: {
+    type: Date,
+    default: Date.now
+  },
+  updated_at: {
+    type: Date,
+    default: Date.now
+  }
+});
+
+module.exports.actionSchema.index({
+  space: 1,
+  created_at: 1
+});
+

+ 364 - 0
models/db.js

@@ -0,0 +1,364 @@
+const Umzug = require('umzug');
+
+function sequel_log(a,b,c) {
+  console.log(a);
+}
+
+const Sequelize = require('sequelize');
+const sequelize = new Sequelize('database', 'username', 'password', {
+  host: 'localhost',
+  dialect: 'sqlite',
+
+  pool: {
+    max: 5,
+    min: 0,
+    acquire: 30000,
+    idle: 10000
+  },
+
+  // SQLite only
+  storage: 'database.sqlite',
+  logging: sequel_log,
+
+  // http://docs.sequelizejs.com/manual/tutorial/querying.html#operators
+  operatorsAliases: false
+});
+
+var User;
+var Session;
+var Space;
+var Membership;
+var Artifact;
+var Message;
+var Action;
+
+module.exports = {
+  User: sequelize.define('user', {
+    _id: {type: Sequelize.STRING, primaryKey: true},
+    email: Sequelize.STRING,
+    password_hash: Sequelize.STRING,
+    nickname: Sequelize.STRING,
+    avatar_original_uri: Sequelize.STRING,
+    avatar_thumb_uri: Sequelize.STRING,
+    confirmation_token: Sequelize.STRING,
+    password_reset_token: Sequelize.STRING,
+    home_folder_id: Sequelize.STRING,
+    prefs_language: Sequelize.STRING,
+    prefs_email_notifications: Sequelize.STRING,
+    prefs_email_digest: Sequelize.STRING,
+    created_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    updated_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW}
+  }),
+
+  Session: sequelize.define('session', {
+    token: {type: Sequelize.STRING, primaryKey: true},
+    user_id: Sequelize.STRING,
+    expires: Sequelize.DATE,
+    created_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    device: Sequelize.STRING,
+    ip: Sequelize.STRING
+  }),
+
+  Space: sequelize.define('space', {
+    _id: {type: Sequelize.STRING, primaryKey: true},
+    name: {type: Sequelize.STRING, default: "New Space"},
+    space_type: {type: Sequelize.STRING, defaultValue: "space"},
+    creator_id: Sequelize.STRING,
+    parent_space_id: Sequelize.STRING,
+
+    access_mode: {type: Sequelize.STRING, default: "private"}, // "public" || "private"
+    password: Sequelize.STRING,
+    edit_hash: Sequelize.STRING,
+    edit_slug: Sequelize.STRING,
+    editors_locking: Sequelize.BOOLEAN,
+
+    thumbnail_uri: Sequelize.STRING,
+
+    width: Sequelize.INTEGER,
+    height: Sequelize.INTEGER,
+    background_color: Sequelize.STRING,
+    background_uri: Sequelize.STRING,
+
+    created_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    updated_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    thumbnail_url: Sequelize.STRING,
+    thumbnail_updated_at: {type: Sequelize.DATE}
+  }),
+
+  Membership: sequelize.define('membership', {
+    _id: {type: Sequelize.STRING, primaryKey: true},
+    space_id: Sequelize.STRING,
+    user_id: Sequelize.STRING,
+    role: Sequelize.STRING,
+    code: Sequelize.STRING,
+    state: {type: Sequelize.STRING, defaultValue: "pending"},
+    created_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    updated_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW}
+  }),
+
+  Message: sequelize.define('message', {
+    _id: {type: Sequelize.STRING, primaryKey: true},
+    space_id: Sequelize.STRING,
+    user_id: Sequelize.STRING,
+    editor_name: Sequelize.STRING,
+    message: Sequelize.TEXT,
+    created_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    updated_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW}
+  }),
+
+  Artifact: sequelize.define('artifact', {
+    _id: {type: Sequelize.STRING, primaryKey: true},
+    space_id: Sequelize.STRING,
+    user_id: Sequelize.STRING,
+
+    mime: Sequelize.STRING,
+    thumbnail_uri: Sequelize.STRING,
+    last_update_user_id: Sequelize.STRING,
+    editor_name: Sequelize.STRING,
+    last_update_editor_name: Sequelize.STRING,
+    description: Sequelize.TEXT,
+    state: {type: Sequelize.STRING, default: "idle"},
+
+    //linked_to: Sequelize.STRING,
+    title: Sequelize.STRING,
+    tags: Sequelize.TEXT,
+    search_text: Sequelize.STRING,
+    link_uri: Sequelize.STRING,
+    play_from: Sequelize.DECIMAL,
+    play_to: Sequelize.DECIMAL,
+
+    x: {type: Sequelize.DECIMAL, default: 0.0},
+    y: {type: Sequelize.DECIMAL, default: 0.0},
+    z: {type: Sequelize.DECIMAL, default: 0.0},
+    r: {type: Sequelize.DECIMAL, default: 0.0},
+    w: {type: Sequelize.DECIMAL, default: 100},
+    h: {type: Sequelize.DECIMAL, default: 100},
+
+    //control_points: [{
+    //  dx: Number, dy: Number
+    //}],
+
+    control_points: Sequelize.TEXT,
+
+    group: Sequelize.STRING,
+    locked: {type: Sequelize.BOOLEAN, default: false},
+
+    payload_uri: Sequelize.STRING,
+    payload_thumbnail_web_uri: Sequelize.STRING,
+    payload_thumbnail_medium_uri: Sequelize.STRING,
+    payload_thumbnail_big_uri: Sequelize.STRING,
+    payload_size: Sequelize.INTEGER, // file size in bytes
+
+    fill_color: {type: Sequelize.STRING, default: "transparent"},
+    stroke_color: {type: Sequelize.STRING, default: "#000000"},
+    text_color: Sequelize.STRING,
+    stroke: {type: Sequelize.DECIMAL, default: 0.0},
+    stroke_style: {type: Sequelize.STRING, default: "solid"},
+    alpha: {type: Sequelize.DECIMAL, default: 1.0},
+    order: {type: Sequelize.INTEGER, default: 0},
+    crop_x: Sequelize.INTEGER,
+    crop_y: Sequelize.INTEGER,
+    crop_w: Sequelize.INTEGER,
+    crop_h: Sequelize.INTEGER,
+    shape: Sequelize.STRING,
+    shape_svg: Sequelize.STRING,
+    padding_left: Sequelize.INTEGER,
+    padding_right: Sequelize.INTEGER,
+    padding_top: Sequelize.INTEGER,
+    padding_bottom: Sequelize.INTEGER,
+    margin_left: Sequelize.INTEGER,
+    margin_right: Sequelize.INTEGER,
+    margin_top: Sequelize.INTEGER,
+    margin_bottom: Sequelize.INTEGER,
+    border_radius: Sequelize.INTEGER,
+    align: {type: Sequelize.STRING, default: "left"},
+    valign: {type: Sequelize.STRING, default: "top"},
+
+    brightness: Sequelize.DECIMAL,
+    contrast: Sequelize.DECIMAL,
+    saturation: Sequelize.DECIMAL,
+    blur: Sequelize.DECIMAL,
+    hue: Sequelize.DECIMAL,
+    opacity: Sequelize.DECIMAL,
+
+    payload_alternatives: Sequelize.TEXT,
+
+    /*payload_alternatives: [{
+      mime: String,
+      payload_uri: String,
+      payload_thumbnail_web_uri: String,
+      payload_thumbnail_medium_uri:  String,
+      payload_thumbnail_big_uri: String,
+      payload_size: Number
+    }],*/
+
+    created_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW},
+    updated_at: {type: Sequelize.DATE, defaultValue: Sequelize.NOW}
+  }),
+
+  init: async function() {
+    User = this.User;
+    Session = this.Session;
+    Space = this.Space;
+    Artifact = this.Artifact;
+    Message = this.Message;
+    Membership = this.Membership;
+
+    Space.belongsTo(User, {
+      foreignKey: {
+        name: 'creator_id'
+      },
+      as: 'creator'
+    });
+
+    Membership.belongsTo(User, {
+      foreignKey: {
+        name: 'user_id'
+      },
+      as: 'user'
+    });
+
+    Membership.belongsTo(Space, {
+      foreignKey: {
+        name: 'space_id'
+      },
+      as: 'space'
+    });
+
+    Artifact.belongsTo(User, {
+      foreignKey: {
+        name: 'user_id'
+      },
+      as: 'user'
+    });
+
+    Artifact.belongsTo(Space, {
+      foreignKey: {
+        name: 'space_id'
+      },
+      as: 'space'
+    });
+
+    Message.belongsTo(User, {
+      foreignKey: {
+        name: 'user_id'
+      },
+      as: 'user'
+    });
+
+    Message.belongsTo(Space, {
+      foreignKey: {
+        name: 'space_id'
+      },
+      as: 'space'
+    });
+
+    await sequelize.sync();
+
+    var umzug = new Umzug({
+        storage: 'sequelize',
+        storageOptions: {
+            sequelize: sequelize
+        },
+        migrations: {
+            params: [
+                sequelize.getQueryInterface(),
+                Sequelize
+            ],
+            path: './models/migrations',
+            pattern: /\.js$/
+        }
+    });
+
+    umzug.up().then(function(migrations)  {
+      console.log('Migration complete up!');
+    });
+
+  },
+
+  getUserRoleInSpace: (originalSpace, user, cb) => {
+    originalSpace.path = [];
+    console.log("getUserRoleInSpace",originalSpace._id,user._id,user.home_folder_id);
+
+    if (originalSpace._id == user.home_folder_id || (originalSpace.creator_id && originalSpace.creator_id == user._id)) {
+      cb("admin");
+    } else {
+      var findMembershipsForSpace = function(space, allMemberships, prevRole) {
+        Membership.findAll({ where: {
+          "space": space._id
+        }}).then(function(parentMemberships) {
+          var currentMemberships = parentMemberships.concat(allMemberships);
+
+          if (space.parent_space_id) {
+            Space.findOne({ where: {
+              "_id": space.parent_space_id
+            }}, function(err, parentSpace) {
+              findMembershipsForSpace(parentSpace, currentMemberships, prevRole);
+            });
+          } else {
+            // reached the top
+            var role = prevRole;
+            space.memberships = currentMemberships;
+
+            if (role == "none") {
+              if (originalSpace.access_mode == "public") {
+                role = "viewer";
+              }
+            }
+
+            currentMemberships.forEach(function(m, i) {
+              if (m.user_id && m.user_id == user._id) {
+                role = m.role;
+              }
+            });
+
+            cb(role);
+          }
+        });
+      };
+      findMembershipsForSpace(originalSpace, [], "none");
+    }
+  },
+
+  spaceToObject: (space) => {
+    // FIXME TODO
+    return space;
+  },
+
+  findUserBySessionToken: (token, cb) => {
+    Session.findOne({where: {token: token}})
+      .then(session => {
+        if (!session) cb(null, null)
+        else User.findOne({where: {_id: session.user_id}})
+          .then(user => {
+            cb(null, user)
+          })
+      })
+  },
+
+  unpackArtifact: (a) => {
+    if (a.tags && (typeof a.tags)=="string") {
+      a.tags = JSON.parse(a.tags);
+    }
+    if (a.control_points && (typeof a.control_points)=="string") {
+      a.control_points = JSON.parse(a.control_points);
+    }
+    if (a.payload_alternatives && (typeof a.payload_alternatives)=="string") {
+      a.payload_alternatives = JSON.parse(a.payload_alternatives);
+    }
+    return a;
+  },
+
+  packArtifact: (a) => {
+    if (a.tags && (typeof a.tags)!="string") {
+      a.tags = JSON.stringify(a.tags);
+    }
+    if (a.control_points && (typeof a.control_points)!="string") {
+      a.control_points = JSON.stringify(a.control_points);
+    }
+    if (a.payload_alternatives && (typeof a.payload_alternatives)!="string") {
+      a.payload_alternatives = JSON.stringify(a.payload_alternatives);
+    }
+    return a;
+  }
+}

+ 80 - 0
models/migrations/01-spaces-delete-cascade.js

@@ -0,0 +1,80 @@
+'use strict';
+
+module.exports = {
+  up: function(migration, DataTypes) {
+    return [
+      migration.changeColumn('memberships', 'space_id',
+        {
+          type: DataTypes.STRING,
+          references: {
+            model: 'spaces',
+            key: '_id'
+          },
+          onDelete: 'CASCADE',
+          onUpdate: 'CASCADE'
+        }
+      ),
+      migration.changeColumn('artifacts', 'space_id',
+        {
+          type: DataTypes.STRING,
+          references: {
+            model: 'spaces',
+            key: '_id'
+          },
+          onDelete: 'CASCADE',
+          onUpdate: 'CASCADE'
+        }
+      ),
+      migration.changeColumn('messages', 'space_id',
+        {
+          type: DataTypes.STRING,
+          references: {
+            model: 'spaces',
+            key: '_id'
+          },
+          onDelete: 'CASCADE',
+          onUpdate: 'CASCADE'
+        }
+      )
+    ]
+  },
+
+  down: function(migration, DataTypes) {
+    return [
+      migration.changeColumn('memberships', 'space_id',
+        {
+          type: DataTypes.STRING,
+          references: {
+            model: 'spaces',
+            key: '_id'
+          },
+          onDelete: 'CASCADE',
+          onUpdate: 'NO ACTION'
+        }
+      ),
+      ,
+      migration.changeColumn('artifacts', 'space_id',
+        {
+          type: DataTypes.STRING,
+          references: {
+            model: 'spaces',
+            key: '_id'
+          },
+          onDelete: 'CASCADE',
+          onUpdate: 'NO ACTION'
+        }
+      ),
+      migration.changeColumn('messages', 'space_id',
+        {
+          type: DataTypes.STRING,
+          references: {
+            model: 'spaces',
+            key: '_id'
+          },
+          onDelete: 'CASCADE',
+          onUpdate: 'NO ACTION'
+        }
+      )
+    ]
+  }
+};

+ 60 - 0
package.json

@@ -0,0 +1,60 @@
+{
+  "name": "spacedeck-open",
+  "version": "0.0.1",
+  "private": true,
+  "scripts": {
+    "start": "electron ."
+  },
+  "engines": {
+    "node": ">=7.8.0"
+  },
+  "dependencies": {
+    "archiver": "1.3.0",
+    "async": "2.3.0",
+    "basic-auth": "1.1.0",
+    "bcryptjs": "2.4.3",
+    "body-parser": "~1.17.1",
+    "cheerio": "0.22.0",
+    "config": "1.25.1",
+    "cookie-parser": "~1.4.3",
+    "electron": "^1.8.4",
+    "execSync": "latest",
+    "express": "~4.13.0",
+    "file-type": "^7.6.0",
+    "glob": "7.1.1",
+    "gm": "1.23.0",
+    "helmet": "^3.5.0",
+    "i18n-2": "0.6.3",
+    "log-timestamp": "latest",
+    "mock-aws-s3": "^2.6.0",
+    "moment": "^2.19.3",
+    "morgan": "1.8.1",
+    "node-phantom-simple": "2.2.4",
+    "nodemailer": "^4.6.7",
+    "phantomjs-prebuilt": "2.1.14",
+    "read-chunk": "^2.1.0",
+    "request": "2.81.0",
+    "sanitize-html": "^1.11.1",
+    "sequelize": "^4.37.6",
+    "serve-favicon": "~2.4.2",
+    "serve-static": "^1.13.1",
+    "slug": "0.9.1",
+    "sqlite3": "^4.0.0",
+    "swig": "1.4.2",
+    "umzug": "^2.1.0",
+    "underscore": "1.8.3",
+    "uuid": "^3.2.1",
+    "validator": "7.0.0",
+    "ws": "2.2.3"
+  },
+  "main": "app.js",
+  "description": "",
+  "directories": {},
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/spacedeck/spacedeck-open.git"
+  },
+  "keywords": [],
+  "author": "Lukas F. Hartmann, Martin Güther",
+  "license": "AGPLv3"
+}

BIN
public/fonts/OpenSans-Regular.ttf


+ 10 - 0
public/fonts/font.scss

@@ -0,0 +1,10 @@
+@font-face {
+    font-family: 'icon';
+    src: url('../fonts/icon-regular-webfont.eot');
+    src: url('../fonts/icon-regular-webfont.eot?#iefix') format('embedded-opentype'),
+         url('../fonts/icon-regular-webfont.woff') format('woff'),
+         url('../fonts/icon-regular-webfont.ttf') format('truetype'),
+         url('../fonts/icon-regular-webfont.svg#iconregular') format('svg');
+    font-weight: normal;
+    font-style: normal;
+}

BIN
public/fonts/icon-regular-webfont.eot


File diff suppressed because it is too large
+ 99 - 0
public/fonts/icon-regular-webfont.svg


BIN
public/fonts/icon-regular-webfont.ttf


BIN
public/fonts/icon-regular-webfont.woff


+ 2648 - 0
public/fonts/unicode.scss

@@ -0,0 +1,2648 @@
+.icon-bullet:before {
+  content: "\2022";
+}
+
+.icon-hyphen:before {
+  content: "\002D";
+}
+
+.icon-Euro:before {
+  content: "\20AC";
+}
+
+.icon-minus:before {
+  content: "\2212";
+}
+
+.icon-plus:before {
+  content: "\002B";
+}
+
+.icon-cloud:before {
+  content: "\2601";
+}
+
+.icon-lightning:before {
+  content: "\2607";
+}
+
+.icon-heartsuitblack:before {
+  content: "\2665";
+}
+
+.icon-whiteFlag:before {
+  content: "\2690";
+}
+
+.icon-blackFlag:before {
+  content: "\2691";
+}
+
+.icon-crossedSwords:before {
+  content: "\2694";
+}
+
+.icon-blackScissorsRight:before {
+  content: "\2702";
+}
+
+.icon-airplane:before {
+  content: "\2708";
+}
+
+.icon-envelope:before {
+  content: "\2709";
+}
+
+.icon-pencil:before {
+  content: "\270F";
+}
+
+.icon-sparkle:before {
+  content: "\2747";
+}
+
+.icon-at:before {
+  content: "\0040";
+}
+
+.icon-copyright:before {
+  content: "\00A9";
+}
+
+.icon-registered:before {
+  content: "\00AE";
+}
+
+.icon-section:before {
+  content: "\00A7";
+}
+
+.icon-battery:before {
+  content: "\1F50B";
+}
+
+.icon-bell:before {
+  content: "\1F514";
+}
+
+.icon-bookmark:before {
+  content: "\1F516";
+}
+
+.icon-calendar:before {
+  content: "\1F4C5";
+}
+
+.icon-camera:before {
+  content: "\1F4F7";
+}
+
+.icon-clipboard:before {
+  content: "\1F4CB";
+}
+
+.icon-hourglass:before {
+  content: "\231B";
+}
+
+.icon-microphone:before {
+  content: "\1F3A4";
+}
+
+.icon-newspaper:before {
+  content: "\1F4F0";
+}
+
+.icon-paperclip:before {
+  content: "\1F4CE";
+}
+
+.icon-radio:before {
+  content: "\1F4FB";
+}
+
+.icon-wrench:before {
+  content: "\1F527";
+}
+
+.icon-align-bottom:before {
+  content: "\E004";
+}
+
+.icon-align-center-horizontal:before {
+  content: "\E005";
+}
+
+.icon-align-center-vertical:before {
+  content: "\E006";
+}
+
+.icon-align-left:before {
+  content: "\E007";
+}
+
+.icon-align-right:before {
+  content: "\E008";
+}
+
+.icon-align-to-canvas:before {
+  content: "\E009";
+}
+
+.icon-align-top:before {
+  content: "\E00A";
+}
+
+.icon-align-vertical-bottom:before {
+  content: "\E00B";
+}
+
+.icon-align-vertical-middle:before {
+  content: "\E00C";
+}
+
+.icon-align-vertical-top:before {
+  content: "\E00D";
+}
+
+.icon-antenna:before {
+  content: "\E00E";
+}
+
+.icon-application:before {
+  content: "\E00F";
+}
+
+.icon-arrow-0:before {
+  content: "\E010";
+}
+
+.icon-arrow-1:before {
+  content: "\E011";
+}
+
+.icon-arrow-2:before {
+  content: "\E012";
+}
+
+.icon-arrow-3:before {
+  content: "\E013";
+}
+
+.icon-arrow-4:before {
+  content: "\E014";
+}
+
+.icon-arrow-bottom-light:before {
+  content: "\E015";
+}
+
+.icon-arrow-down:before {
+  content: "\E016";
+}
+
+.icon-arrow-fork:before {
+  content: "\E017";
+}
+
+.icon-arrow-horizontal:before {
+  content: "\E018";
+}
+
+.icon-arrow-left:before {
+  content: "\E019";
+}
+
+.icon-arrow-left-down:before {
+  content: "\E01A";
+}
+
+.icon-arrow-left-light:before {
+  content: "\E01B";
+}
+
+.icon-arrow-left-up:before {
+  content: "\E01C";
+}
+
+.icon-arrow-merge:before {
+  content: "\E01D";
+}
+
+.icon-arrow-right:before {
+  content: "\E01E";
+}
+
+.icon-arrow-right-down:before {
+  content: "\E01F";
+}
+
+.icon-arrow-right-light:before {
+  content: "\E020";
+}
+
+.icon-arrow-right-up:before {
+  content: "\E021";
+}
+
+.icon-arrow-thin-down:before {
+  content: "\E022";
+}
+
+.icon-arrow-thin-left:before {
+  content: "\E023";
+}
+
+.icon-arrow-thin-right:before {
+  content: "\E024";
+}
+
+.icon-arrow-thin-up:before {
+  content: "\E025";
+}
+
+.icon-arrow-top:before {
+  content: "\E026";
+}
+
+.icon-arrow-top-light:before {
+  content: "\E027";
+}
+
+.icon-arrow-vertical:before {
+  content: "\E028";
+}
+
+.icon-arrows-horizontal:before {
+  content: "\E029";
+}
+
+.icon-arrows-in:before {
+  content: "\E02A";
+}
+
+.icon-arrows-out:before {
+  content: "\E02B";
+}
+
+.icon-arrows-thin-horizontal:before {
+  content: "\E02C";
+}
+
+.icon-arrows-thin-vertical:before {
+  content: "\E02D";
+}
+
+.icon-arrows-vertical:before {
+  content: "\E02E";
+}
+
+.icon-ban-0:before {
+  content: "\E02F";
+}
+
+.icon-ban-1:before {
+  content: "\E030";
+}
+
+.icon-ban-2:before {
+  content: "\E031";
+}
+
+.icon-ban-3:before {
+  content: "\E032";
+}
+
+.icon-ban-4:before {
+  content: "\E033";
+}
+
+.icon-bar-code:before {
+  content: "\E034";
+}
+
+.icon-battery-0:before {
+  content: "\E035";
+}
+
+.icon-battery-1:before {
+  content: "\E036";
+}
+
+.icon-battery-2:before {
+  content: "\E037";
+}
+
+.icon-battery-3:before {
+  content: "\E038";
+}
+
+.icon-battery-4:before {
+  content: "\E039";
+}
+
+.icon-battery-5:before {
+  content: "\E03A";
+}
+
+.icon-battery-6:before {
+  content: "\E03B";
+}
+
+.icon-battery-7:before {
+  content: "\E03C";
+}
+
+.icon-battery-8:before {
+  content: "\E03D";
+}
+
+.icon-battery-loading:before {
+  content: "\E03E";
+}
+
+.icon-beverage-beer:before {
+  content: "\E03F";
+}
+
+.icon-beverage-cocktail:before {
+  content: "\E040";
+}
+
+.icon-beverage-wine:before {
+  content: "\E041";
+}
+
+.icon-book:before {
+  content: "\E042";
+}
+
+.icon-book-contacts:before {
+  content: "\E043";
+}
+
+.icon-book-open:before {
+  content: "\E044";
+}
+
+.icon-border-dashed:before {
+  content: "\E045";
+}
+
+.icon-border-dotted:before {
+  content: "\E046";
+}
+
+.icon-border-radius:before {
+  content: "\E047";
+}
+
+.icon-border-solid:before {
+  content: "\E048";
+}
+
+.icon-brightness:before {
+  content: "\E049";
+}
+
+.icon-calculator:before {
+  content: "\E04A";
+}
+
+.icon-calculator-black:before {
+  content: "\E04B";
+}
+
+.icon-camera-black:before {
+  content: "\E04C";
+}
+
+.icon-canvas:before {
+  content: "\E04D";
+}
+
+.icon-canvas-square:before {
+  content: "\E04E";
+}
+
+.icon-cd:before {
+  content: "\E04F";
+}
+
+.icon-certificate:before {
+  content: "\E050";
+}
+
+.icon-chart-0:before {
+  content: "\E051";
+}
+
+.icon-chart-1:before {
+  content: "\E052";
+}
+
+.icon-chart-2:before {
+  content: "\E053";
+}
+
+.icon-chart-3:before {
+  content: "\E054";
+}
+
+.icon-chart-4:before {
+  content: "\E055";
+}
+
+.icon-chart-5:before {
+  content: "\E056";
+}
+
+.icon-chart-6:before {
+  content: "\E057";
+}
+
+.icon-chart-7:before {
+  content: "\E058";
+}
+
+.icon-chart-8:before {
+  content: "\E059";
+}
+
+.icon-chart-bars:before {
+  content: "\E05A";
+}
+
+.icon-check:before {
+  content: "\E05B";
+}
+
+.icon-chevron-down-0:before {
+  content: "\E05C";
+}
+
+.icon-chevron-down-1:before {
+  content: "\E05D";
+}
+
+.icon-chevron-down-2:before {
+  content: "\E05E";
+}
+
+.icon-chevron-down-3:before {
+  content: "\E05F";
+}
+
+.icon-chevron-down-4:before {
+  content: "\E060";
+}
+
+.icon-chevron-left-0:before {
+  content: "\E061";
+}
+
+.icon-chevron-left-1:before {
+  content: "\E062";
+}
+
+.icon-chevron-left-2:before {
+  content: "\E063";
+}
+
+.icon-chevron-left-3:before {
+  content: "\E064";
+}
+
+.icon-chevron-left-4:before {
+  content: "\E065";
+}
+
+.icon-chevron-right-0:before {
+  content: "\E066";
+}
+
+.icon-chevron-right-1:before {
+  content: "\E067";
+}
+
+.icon-chevron-right-2:before {
+  content: "\E068";
+}
+
+.icon-chevron-right-3:before {
+  content: "\E069";
+}
+
+.icon-chevron-right-4:before {
+  content: "\E06A";
+}
+
+.icon-chevron-up-0:before {
+  content: "\E06B";
+}
+
+.icon-chevron-up-1:before {
+  content: "\E06C";
+}
+
+.icon-chevron-up-2:before {
+  content: "\E06D";
+}
+
+.icon-chevron-up-3:before {
+  content: "\E06E";
+}
+
+.icon-chevron-up-4:before {
+  content: "\E06F";
+}
+
+.icon-circle-0:before {
+  content: "\E070";
+}
+
+.icon-circle-1:before {
+  content: "\E071";
+}
+
+.icon-circle-2:before {
+  content: "\E072";
+}
+
+.icon-circle-3:before {
+  content: "\E073";
+}
+
+.icon-circle-4:before {
+  content: "\E074";
+}
+
+.icon-circle-check:before {
+  content: "\E075";
+}
+
+.icon-circle-down:before {
+  content: "\E076";
+}
+
+.icon-circle-exclude:before {
+  content: "\E077";
+}
+
+.icon-circle-info:before {
+  content: "\E078";
+}
+
+.icon-circle-intersect:before {
+  content: "\E079";
+}
+
+.icon-circle-left:before {
+  content: "\E07A";
+}
+
+.icon-circle-minus:before {
+  content: "\E07B";
+}
+
+.icon-circle-plus:before {
+  content: "\E07C";
+}
+
+.icon-circle-question:before {
+  content: "\E07D";
+}
+
+.icon-circle-remove:before {
+  content: "\E07E";
+}
+
+.icon-circle-right:before {
+  content: "\E07F";
+}
+
+.icon-circle-subtract:before {
+  content: "\E080";
+}
+
+.icon-circle-unite:before {
+  content: "\E081";
+}
+
+.icon-circle-up:before {
+  content: "\E082";
+}
+
+.icon-circle-warning:before {
+  content: "\E083";
+}
+
+.icon-clock:before {
+  content: "\E084";
+}
+
+.icon-cloud-down:before {
+  content: "\E085";
+}
+
+.icon-cloud-upload:before {
+  content: "\E086";
+}
+
+.icon-cluster:before {
+  content: "\E087";
+}
+
+.icon-cogwheel:before {
+  content: "\E088";
+}
+
+.icon-columns:before {
+  content: "\E089";
+}
+
+.icon-columns-3:before {
+  content: "\E08A";
+}
+
+.icon-compass:before {
+  content: "\E08B";
+}
+
+.icon-compass-east:before {
+  content: "\E08C";
+}
+
+.icon-compass-north:before {
+  content: "\E08D";
+}
+
+.icon-compass-north-east:before {
+  content: "\E08E";
+}
+
+.icon-compass-north-west:before {
+  content: "\E08F";
+}
+
+.icon-compass-south:before {
+  content: "\E090";
+}
+
+.icon-compass-south-east:before {
+  content: "\E091";
+}
+
+.icon-compass-south-west:before {
+  content: "\E092";
+}
+
+.icon-compass-west:before {
+  content: "\E093";
+}
+
+.icon-contrast:before {
+  content: "\E094";
+}
+
+.icon-controls-backward:before {
+  content: "\E095";
+}
+
+.icon-controls-eject:before {
+  content: "\E096";
+}
+
+.icon-controls-fast-backward:before {
+  content: "\E097";
+}
+
+.icon-controls-fast-forward:before {
+  content: "\E098";
+}
+
+.icon-controls-forward:before {
+  content: "\E099";
+}
+
+.icon-controls-pause:before {
+  content: "\E09A";
+}
+
+.icon-controls-play:before {
+  content: "\E09B";
+}
+
+.icon-controls-stop:before {
+  content: "\E09C";
+}
+
+.icon-couple:before {
+  content: "\E09D";
+}
+
+.icon-creditcard:before {
+  content: "\E09E";
+}
+
+.icon-cross-0:before {
+  content: "\E09F";
+}
+
+.icon-cross-1:before {
+  content: "\E0A0";
+}
+
+.icon-cross-2:before {
+  content: "\E0A1";
+}
+
+.icon-cross-3:before {
+  content: "\E0A2";
+}
+
+.icon-cross-4:before {
+  content: "\E0A3";
+}
+
+.icon-cross-hair:before {
+  content: "\E0A4";
+}
+
+.icon-cullumn-spacing:before {
+  content: "\E0A5";
+}
+
+.icon-currency-dollar:before {
+  content: "\E0A6";
+}
+
+.icon-currency-pound:before {
+  content: "\E0A7";
+}
+
+.icon-cursor-text:before {
+  content: "\E0A8";
+}
+
+.icon-cursor-text-frame:before {
+  content: "\E0A9";
+}
+
+.icon-curved-arrow:before {
+  content: "\E0AA";
+}
+
+.icon-cuttlery:before {
+  content: "\E0AB";
+}
+
+.icon-dashboard:before {
+  content: "\E0AC";
+}
+
+.icon-device-desktop:before {
+  content: "\E0AD";
+}
+
+.icon-device-mobile:before {
+  content: "\E0AE";
+}
+
+.icon-device-mobile-down:before {
+  content: "\E0AF";
+}
+
+.icon-device-mobile-transfer:before {
+  content: "\E0B0";
+}
+
+.icon-device-mobile-up:before {
+  content: "\E0B1";
+}
+
+.icon-device-mp3:before {
+  content: "\E0B2";
+}
+
+.icon-device-notebook:before {
+  content: "\E0B3";
+}
+
+.icon-device-tablet:before {
+  content: "\E0B4";
+}
+
+.icon-directions-axis:before {
+  content: "\E0B5";
+}
+
+.icon-directions-omni:before {
+  content: "\E0B6";
+}
+
+.icon-directions-seperate:before {
+  content: "\E0B7";
+}
+
+.icon-distribute-horizontal:before {
+  content: "\E0B8";
+}
+
+.icon-distribute-vertical:before {
+  content: "\E0B9";
+}
+
+.icon-download:before {
+  content: "\E0BA";
+}
+
+.icon-duplicate:before {
+  content: "\E0BB";
+}
+
+.icon-duplicate-alt:before {
+  content: "\E0BC";
+}
+
+.icon-edge-bottom:before {
+  content: "\E0BD";
+}
+
+.icon-edge-left:before {
+  content: "\E0BE";
+}
+
+.icon-edge-right:before {
+  content: "\E0BF";
+}
+
+.icon-edge-top:before {
+  content: "\E0C0";
+}
+
+.icon-ellipse-horizontal:before {
+  content: "\E0C1";
+}
+
+.icon-ellipse-vertical:before {
+  content: "\E0C2";
+}
+
+.icon-embed:before {
+  content: "\E0C3";
+}
+
+.icon-embed-alt:before {
+  content: "\E0C4";
+}
+
+.icon-emote-1:before {
+  content: "\E0C5";
+}
+
+.icon-emote-2:before {
+  content: "\E0C6";
+}
+
+.icon-emote-3:before {
+  content: "\E0C7";
+}
+
+.icon-emote-4:before {
+  content: "\E0C8";
+}
+
+.icon-emote-big-smile:before {
+  content: "\E0C9";
+}
+
+.icon-emote-only-happy:before {
+  content: "\E0CA";
+}
+
+.icon-emote-sad:before {
+  content: "\E0CB";
+}
+
+.icon-emote-smile:before {
+  content: "\E0CC";
+}
+
+.icon-emote-tongue:before {
+  content: "\E0CD";
+}
+
+.icon-escape:before {
+  content: "\E0CE";
+}
+
+.icon-eye-closed:before {
+  content: "\E0CF";
+}
+
+.icon-eye-open:before {
+  content: "\E0D0";
+}
+
+.icon-feather:before {
+  content: "\E0D1";
+}
+
+.icon-female:before {
+  content: "\E0D2";
+}
+
+.icon-female-afro:before {
+  content: "\E0D3";
+}
+
+.icon-female-bun:before {
+  content: "\E0D4";
+}
+
+.icon-female-emo:before {
+  content: "\E0D5";
+}
+
+.icon-female-long-hair:before {
+  content: "\E0D6";
+}
+
+.icon-female-moslem:before {
+  content: "\E0D7";
+}
+
+.icon-female-pigtails:before {
+  content: "\E0D8";
+}
+
+.icon-female-pigtails-alt:before {
+  content: "\E0D9";
+}
+
+.icon-female-user:before {
+  content: "\E0DA";
+}
+
+.icon-fetch:before {
+  content: "\E0DB";
+}
+
+.icon-file-check:before {
+  content: "\E0DC";
+}
+
+.icon-file-edit:before {
+  content: "\E0DD";
+}
+
+.icon-file-share:before {
+  content: "\E0DE";
+}
+
+.icon-fit-to:before {
+  content: "\E0DF";
+}
+
+.icon-flag:before {
+  content: "\E0E0";
+}
+
+.icon-flip-down:before {
+  content: "\E0E1";
+}
+
+.icon-flip-left:before {
+  content: "\E0E2";
+}
+
+.icon-flip-right:before {
+  content: "\E0E3";
+}
+
+.icon-flip-up:before {
+  content: "\E0E4";
+}
+
+.icon-floppydisk:before {
+  content: "\E0E5";
+}
+
+.icon-flow:before {
+  content: "\E0E6";
+}
+
+.icon-folder:before {
+  content: "\E0E7";
+}
+
+.icon-folder-down:before {
+  content: "\E0E8";
+}
+
+.icon-folder-favorites:before {
+  content: "\E0E9";
+}
+
+.icon-folder-flagged:before {
+  content: "\E0EA";
+}
+
+.icon-folder-leave:before {
+  content: "\E0EB";
+}
+
+.icon-folder-locked:before {
+  content: "\E0EC";
+}
+
+.icon-folder-minus:before {
+  content: "\E0ED";
+}
+
+.icon-folder-open:before {
+  content: "\E0EE";
+}
+
+.icon-folder-open-2:before {
+  content: "\E0EF";
+}
+
+.icon-folder-plus:before {
+  content: "\E0F0";
+}
+
+.icon-folder-remove:before {
+  content: "\E0F1";
+}
+
+.icon-folder-shared:before {
+  content: "\E0F2";
+}
+
+.icon-folder-up:before {
+  content: "\E0F3";
+}
+
+.icon-form-exclude:before {
+  content: "\E0F4";
+}
+
+.icon-form-intersect:before {
+  content: "\E0F5";
+}
+
+.icon-form-subtract:before {
+  content: "\E0F6";
+}
+
+.icon-form-unite:before {
+  content: "\E0F7";
+}
+
+.icon-full-screen:before {
+  content: "\E0F8";
+}
+
+.icon-fullscreen-off:before {
+  content: "\E0F9";
+}
+
+.icon-fullscreen-on:before {
+  content: "\E0FA";
+}
+
+.icon-globe:before {
+  content: "\E0FB";
+}
+
+.icon-grid:before {
+  content: "\E0FC";
+}
+
+.icon-grid-free:before {
+  content: "\E0FD";
+}
+
+.icon-grid-small:before {
+  content: "\E0FE";
+}
+
+.icon-group:before {
+  content: "\E0FF";
+}
+
+.icon-handle:before {
+  content: "\E100";
+}
+
+.icon-harddrive:before {
+  content: "\E101";
+}
+
+.icon-heading:before {
+  content: "\E102";
+}
+
+.icon-headphones:before {
+  content: "\E103";
+}
+
+.icon-heart:before {
+  content: "\E104";
+}
+
+.icon-heart-hollow:before {
+  content: "\E105";
+}
+
+.icon-history:before {
+  content: "\E106";
+}
+
+.icon-home:before {
+  content: "\E107";
+}
+
+.icon-human:before {
+  content: "\E108";
+}
+
+.icon-inbox:before {
+  content: "\E109";
+}
+
+.icon-info:before {
+  content: "\E10A";
+}
+
+.icon-input-checkbox:before {
+  content: "\E10B";
+}
+
+.icon-input-checkbox-checked:before {
+  content: "\E10C";
+}
+
+.icon-input-radio:before {
+  content: "\E10D";
+}
+
+.icon-input-radio-checked:before {
+  content: "\E10E";
+}
+
+.icon-leave:before {
+  content: "\E10F";
+}
+
+.icon-lightbulb:before {
+  content: "\E110";
+}
+
+.icon-line-0:before {
+  content: "\E111";
+}
+
+.icon-line-1:before {
+  content: "\E112";
+}
+
+.icon-line-2:before {
+  content: "\E113";
+}
+
+.icon-line-3:before {
+  content: "\E114";
+}
+
+.icon-line-4:before {
+  content: "\E115";
+}
+
+.icon-line-half-0:before {
+  content: "\E116";
+}
+
+.icon-line-half-1:before {
+  content: "\E117";
+}
+
+.icon-line-half-2:before {
+  content: "\E118";
+}
+
+.icon-line-half-3:before {
+  content: "\E119";
+}
+
+.icon-line-half-4:before {
+  content: "\E11A";
+}
+
+.icon-lines:before {
+  content: "\E11B";
+}
+
+.icon-link:before {
+  content: "\E11C";
+}
+
+.icon-link-closed:before {
+  content: "\E11D";
+}
+
+.icon-link-closed-alt:before {
+  content: "\E11E";
+}
+
+.icon-link-open:before {
+  content: "\E11F";
+}
+
+.icon-link-open-alt:before {
+  content: "\E120";
+}
+
+.icon-list:before {
+  content: "\E121";
+}
+
+.icon-location:before {
+  content: "\E122";
+}
+
+.icon-lock-closed:before {
+  content: "\E123";
+}
+
+.icon-lock-open:before {
+  content: "\E124";
+}
+
+.icon-login:before {
+  content: "\E125";
+}
+
+.icon-logout:before {
+  content: "\E126";
+}
+
+.icon-loop:before {
+  content: "\E127";
+}
+
+.icon-magnet:before {
+  content: "\E128";
+}
+
+.icon-mail:before {
+  content: "\E129";
+}
+
+.icon-male:before {
+  content: "\E12A";
+}
+
+.icon-male-add:before {
+  content: "\E12B";
+}
+
+.icon-male-afro:before {
+  content: "\E12C";
+}
+
+.icon-male-business:before {
+  content: "\E12D";
+}
+
+.icon-male-cool:before {
+  content: "\E12E";
+}
+
+.icon-male-emo:before {
+  content: "\E12F";
+}
+
+.icon-male-flathat:before {
+  content: "\E130";
+}
+
+.icon-male-gentleman:before {
+  content: "\E131";
+}
+
+.icon-male-hoodie:before {
+  content: "\E132";
+}
+
+.icon-male-jew:before {
+  content: "\E133";
+}
+
+.icon-male-monk:before {
+  content: "\E134";
+}
+
+.icon-male-moslem:before {
+  content: "\E135";
+}
+
+.icon-male-mustache:before {
+  content: "\E136";
+}
+
+.icon-male-punk:before {
+  content: "\E137";
+}
+
+.icon-male-remove:before {
+  content: "\E138";
+}
+
+.icon-male-scuba:before {
+  content: "\E139";
+}
+
+.icon-male-soldier:before {
+  content: "\E13A";
+}
+
+.icon-male-techno:before {
+  content: "\E13B";
+}
+
+.icon-male-user:before {
+  content: "\E13C";
+}
+
+.icon-megaphone:before {
+  content: "\E13D";
+}
+
+.icon-menu:before {
+  content: "\E13E";
+}
+
+.icon-menu-2:before {
+  content: "\E13F";
+}
+
+.icon-message:before {
+  content: "\E140";
+}
+
+.icon-message-add:before {
+  content: "\E141";
+}
+
+.icon-message-blank:before {
+  content: "\E142";
+}
+
+.icon-messages:before {
+  content: "\E143";
+}
+
+.icon-microphone-off:before {
+  content: "\E144";
+}
+
+.icon-mirror-horizontal:before {
+  content: "\E145";
+}
+
+.icon-mirror-horizontal-alt:before {
+  content: "\E146";
+}
+
+.icon-mirror-vertical:before {
+  content: "\E147";
+}
+
+.icon-mirror-vertical-alt:before {
+  content: "\E148";
+}
+
+.icon-moon-first-quarter:before {
+  content: "\E149";
+}
+
+.icon-moon-full:before {
+  content: "\E14A";
+}
+
+.icon-moon-new:before {
+  content: "\E14B";
+}
+
+.icon-moon-third-quarter:before {
+  content: "\E14C";
+}
+
+.icon-moon-waning-crescent:before {
+  content: "\E14D";
+}
+
+.icon-moon-waning-gibbous:before {
+  content: "\E14E";
+}
+
+.icon-moon-waxing-crescent:before {
+  content: "\E14F";
+}
+
+.icon-moon-waxing-gibbous:before {
+  content: "\E150";
+}
+
+.icon-move:before {
+  content: "\E151";
+}
+
+.icon-move-down:before {
+  content: "\E152";
+}
+
+.icon-move-left:before {
+  content: "\E153";
+}
+
+.icon-move-right:before {
+  content: "\E154";
+}
+
+.icon-move-up:before {
+  content: "\E155";
+}
+
+.icon-movie:before {
+  content: "\E156";
+}
+
+.icon-music-note:before {
+  content: "\E157";
+}
+
+.icon-music-upload:before {
+  content: "\E158";
+}
+
+.icon-newspaper-alt:before {
+  content: "\E159";
+}
+
+.icon-padding:before {
+  content: "\E15A";
+}
+
+.icon-padding-bottom:before {
+  content: "\E15B";
+}
+
+.icon-padding-left:before {
+  content: "\E15C";
+}
+
+.icon-padding-right:before {
+  content: "\E15D";
+}
+
+.icon-padding-top:before {
+  content: "\E15E";
+}
+
+.icon-page-horizontal:before {
+  content: "\E15F";
+}
+
+.icon-page-horizontal-down:before {
+  content: "\E160";
+}
+
+.icon-page-horizontal-flag:before {
+  content: "\E161";
+}
+
+.icon-page-horizontal-locked:before {
+  content: "\E162";
+}
+
+.icon-page-horizontal-minus:before {
+  content: "\E163";
+}
+
+.icon-page-horizontal-plus:before {
+  content: "\E164";
+}
+
+.icon-page-horizontal-remove:before {
+  content: "\E165";
+}
+
+.icon-page-horizontal-up:before {
+  content: "\E166";
+}
+
+.icon-page-vert:before {
+  content: "\E167";
+}
+
+.icon-page-vertical-double:before {
+  content: "\E168";
+}
+
+.icon-page-vertical-down:before {
+  content: "\E169";
+}
+
+.icon-page-vertical-flag:before {
+  content: "\E16A";
+}
+
+.icon-page-vertical-image:before {
+  content: "\E16B";
+}
+
+.icon-page-vertical-locked:before {
+  content: "\E16C";
+}
+
+.icon-page-vertical-minus:before {
+  content: "\E16D";
+}
+
+.icon-page-vertical-plus:before {
+  content: "\E16E";
+}
+
+.icon-page-vertical-remove:before {
+  content: "\E16F";
+}
+
+.icon-page-vertical-table:before {
+  content: "\E170";
+}
+
+.icon-page-vertical-text:before {
+  content: "\E171";
+}
+
+.icon-page-vertical-up:before {
+  content: "\E172";
+}
+
+.icon-palette:before {
+  content: "\E173";
+}
+
+.icon-palette-alt:before {
+  content: "\E174";
+}
+
+.icon-paperplane:before {
+  content: "\E175";
+}
+
+.icon-photofilm:before {
+  content: "\E176";
+}
+
+.icon-picture:before {
+  content: "\E177";
+}
+
+.icon-picture-landscape:before {
+  content: "\E178";
+}
+
+.icon-picture-portrait:before {
+  content: "\E179";
+}
+
+.icon-picture-upload:before {
+  content: "\E17A";
+}
+
+.icon-pictures:before {
+  content: "\E17B";
+}
+
+.icon-pin:before {
+  content: "\E17C";
+}
+
+.icon-planet:before {
+  content: "\E001";
+}
+
+.icon-point:before {
+  content: "\E17D";
+}
+
+.icon-pointing-down:before {
+  content: "\E17E";
+}
+
+.icon-pointing-left:before {
+  content: "\E17F";
+}
+
+.icon-pointing-right:before {
+  content: "\E180";
+}
+
+.icon-pointing-up:before {
+  content: "\E181";
+}
+
+.icon-postcard:before {
+  content: "\E182";
+}
+
+.icon-power-off:before {
+  content: "\E183";
+}
+
+.icon-present:before {
+  content: "\E184";
+}
+
+.icon-presentation:before {
+  content: "\E185";
+}
+
+.icon-printer:before {
+  content: "\E186";
+}
+
+.icon-pull:before {
+  content: "\E187";
+}
+
+.icon-push:before {
+  content: "\E188";
+}
+
+.icon-qr-code:before {
+  content: "\E189";
+}
+
+.icon-quote:before {
+  content: "\E18A";
+}
+
+.icon-radio-black:before {
+  content: "\E18B";
+}
+
+.icon-random:before {
+  content: "\E18C";
+}
+
+.icon-record:before {
+  content: "\E18D";
+}
+
+.icon-redo:before {
+  content: "\E18E";
+}
+
+.icon-resize-full:before {
+  content: "\E18F";
+}
+
+.icon-resize-small:before {
+  content: "\E190";
+}
+
+.icon-retweet:before {
+  content: "\E191";
+}
+
+.icon-rings:before {
+  content: "\E192";
+}
+
+.icon-road:before {
+  content: "\E193";
+}
+
+.icon-rotate:before {
+  content: "\E194";
+}
+
+.icon-rotate-2:before {
+  content: "\E195";
+}
+
+.icon-rotate-3:before {
+  content: "\E196";
+}
+
+.icon-rotate-left:before {
+  content: "\E197";
+}
+
+.icon-rotate-right:before {
+  content: "\E198";
+}
+
+.icon-rss:before {
+  content: "\E199";
+}
+
+.icon-search:before {
+  content: "\E000";
+}
+
+.icon-section-alt:before {
+  content: "\E19A";
+}
+
+.icon-section-alt-2:before {
+  content: "\E19B";
+}
+
+.icon-section-edit:before {
+  content: "\E19C";
+}
+
+.icon-selection:before {
+  content: "\E19D";
+}
+
+.icon-selection-circle:before {
+  content: "\E19E";
+}
+
+.icon-selection-circle-strong:before {
+  content: "\E19F";
+}
+
+.icon-selection-square:before {
+  content: "\E1A0";
+}
+
+.icon-selection-square-strong:before {
+  content: "\E1A1";
+}
+
+.icon-server:before {
+  content: "\E1A2";
+}
+
+.icon-shape-bubble:before {
+  content: "\E1A3";
+}
+
+.icon-shape-burst:before {
+  content: "\E1A4";
+}
+
+.icon-shape-circle:before {
+  content: "\E1A5";
+}
+
+.icon-shape-cloud:before {
+  content: "\E1A6";
+}
+
+.icon-shape-cross:before {
+  content: "\E1A7";
+}
+
+.icon-shape-heart:before {
+  content: "\E1A8";
+}
+
+.icon-shape-hexagon:before {
+  content: "\E1A9";
+}
+
+.icon-shape-octagon:before {
+  content: "\E1AA";
+}
+
+.icon-shape-pentagon:before {
+  content: "\E1AB";
+}
+
+.icon-shape-plus:before {
+  content: "\E1AC";
+}
+
+.icon-shape-square:before {
+  content: "\E1AD";
+}
+
+.icon-shape-star:before {
+  content: "\E1AE";
+}
+
+.icon-shape-triangle:before {
+  content: "\E1AF";
+}
+
+.icon-shapes:before {
+  content: "\E1B0";
+}
+
+.icon-share:before {
+  content: "\E1B1";
+}
+
+.icon-shopping-cart:before {
+  content: "\E1B2";
+}
+
+.icon-signal:before {
+  content: "\E1B3";
+}
+
+.icon-signal-alt:before {
+  content: "\E1B4";
+}
+
+.icon-size:before {
+  content: "\E1B5";
+}
+
+.icon-size-both:before {
+  content: "\E1B6";
+}
+
+.icon-size-horizontal:before {
+  content: "\E1B7";
+}
+
+.icon-size-vertical:before {
+  content: "\E1B8";
+}
+
+.icon-social-dribbble:before {
+  content: "\E1B9";
+}
+
+.icon-social-dropbox:before {
+  content: "\E1BA";
+}
+
+.icon-social-facebook:before {
+  content: "\E1BB";
+}
+
+.icon-social-flickr:before {
+  content: "\E1BC";
+}
+
+.icon-social-google:before {
+  content: "\E1BD";
+}
+
+.icon-social-soundcloud:before {
+  content: "\E002";
+}
+
+.icon-social-square-dribbble:before {
+  content: "\E1BE";
+}
+
+.icon-social-square-dropbox:before {
+  content: "\E1BF";
+}
+
+.icon-social-square-facebook:before {
+  content: "\E1C0";
+}
+
+.icon-social-square-flickr:before {
+  content: "\E1C1";
+}
+
+.icon-social-square-google:before {
+  content: "\E1C2";
+}
+
+.icon-social-square-soundcloud:before {
+  content: "\E003";
+}
+
+.icon-social-square-tumblr:before {
+  content: "\E1C3";
+}
+
+.icon-social-square-twitter:before {
+  content: "\E1C4";
+}
+
+.icon-social-square-vimeo:before {
+  content: "\E1C5";
+}
+
+.icon-social-square-vine:before {
+  content: "\E1C6";
+}
+
+.icon-social-square-youtube:before {
+  content: "\E1C7";
+}
+
+.icon-social-tumblr:before {
+  content: "\E1C8";
+}
+
+.icon-social-twitter:before {
+  content: "\E1C9";
+}
+
+.icon-social-vimeo:before {
+  content: "\E1CA";
+}
+
+.icon-social-vine:before {
+  content: "\E1CB";
+}
+
+.icon-social-youtube:before {
+  content: "\E1CC";
+}
+
+.icon-sort-alphabetical-ascending:before {
+  content: "\E1CD";
+}
+
+.icon-sort-alphabetical-descending:before {
+  content: "\E1CE";
+}
+
+.icon-sort-ascending:before {
+  content: "\E1CF";
+}
+
+.icon-sort-descending:before {
+  content: "\E1D0";
+}
+
+.icon-sort-numerical-ascending:before {
+  content: "\E1D1";
+}
+
+.icon-sort-numerical-descending:before {
+  content: "\E1D2";
+}
+
+.icon-sorting:before {
+  content: "\E1D3";
+}
+
+.icon-sorting-ascending:before {
+  content: "\E1D4";
+}
+
+.icon-sorting-descending:before {
+  content: "\E1D5";
+}
+
+.icon-sound-5-1:before {
+  content: "\E1D6";
+}
+
+.icon-sound-6-1:before {
+  content: "\E1D7";
+}
+
+.icon-sound-7-1:before {
+  content: "\E1D8";
+}
+
+.icon-sound-dolby:before {
+  content: "\E1D9";
+}
+
+.icon-sound-full:before {
+  content: "\E1DA";
+}
+
+.icon-sound-off:before {
+  content: "\E1DB";
+}
+
+.icon-sound-silent:before {
+  content: "\E1DC";
+}
+
+.icon-sound-stereo:before {
+  content: "\E1DD";
+}
+
+.icon-space-horizontal:before {
+  content: "\E1DE";
+}
+
+.icon-space-shared:before {
+  content: "\E1DF";
+}
+
+.icon-space-vertical:before {
+  content: "\E1E0";
+}
+
+.icon-spacedeck-logo:before {
+  content: "\E1E1";
+}
+
+.icon-spacing-horizontal:before {
+  content: "\E1E2";
+}
+
+.icon-spacing-vertical:before {
+  content: "\E1E3";
+}
+
+.icon-square-down:before {
+  content: "\E1E4";
+}
+
+.icon-square-left:before {
+  content: "\E1E5";
+}
+
+.icon-square-right:before {
+  content: "\E1E6";
+}
+
+.icon-square-up:before {
+  content: "\E1E7";
+}
+
+.icon-stack-3d:before {
+  content: "\E1E8";
+}
+
+.icon-stack-3d-bottom:before {
+  content: "\E1E9";
+}
+
+.icon-stack-3d-top:before {
+  content: "\E1EA";
+}
+
+.icon-stack-bottom:before {
+  content: "\E1EB";
+}
+
+.icon-stack-down:before {
+  content: "\E1EC";
+}
+
+.icon-stack-top:before {
+  content: "\E1ED";
+}
+
+.icon-stack-up:before {
+  content: "\E1EE";
+}
+
+.icon-star:before {
+  content: "\E1EF";
+}
+
+.icon-star-hollow:before {
+  content: "\E1F0";
+}
+
+.icon-stash-apply:before {
+  content: "\E1F1";
+}
+
+.icon-stash-save:before {
+  content: "\E1F2";
+}
+
+.icon-statistic:before {
+  content: "\E1F3";
+}
+
+.icon-step-backward:before {
+  content: "\E1F4";
+}
+
+.icon-step-forward:before {
+  content: "\E1F5";
+}
+
+.icon-stroke-weight:before {
+  content: "\E1F6";
+}
+
+.icon-subtitles:before {
+  content: "\E1F7";
+}
+
+.icon-suitcase:before {
+  content: "\E1F8";
+}
+
+.icon-table:before {
+  content: "\E1F9";
+}
+
+.icon-tag:before {
+  content: "\E1FA";
+}
+
+.icon-tags:before {
+  content: "\E1FB";
+}
+
+.icon-text-align-center:before {
+  content: "\E1FC";
+}
+
+.icon-text-align-center-alt:before {
+  content: "\E1FD";
+}
+
+.icon-text-align-justify:before {
+  content: "\E1FE";
+}
+
+.icon-text-align-justify-alt:before {
+  content: "\E1FF";
+}
+
+.icon-text-align-left:before {
+  content: "\E200";
+}
+
+.icon-text-align-left-alt:before {
+  content: "\E201";
+}
+
+.icon-text-align-right:before {
+  content: "\E202";
+}
+
+.icon-text-align-right-alt:before {
+  content: "\E203";
+}
+
+.icon-text-allcaps:before {
+  content: "\E204";
+}
+
+.icon-text-bold:before {
+  content: "\E205";
+}
+
+.icon-text-frame:before {
+  content: "\E206";
+}
+
+.icon-text-indent:before {
+  content: "\E207";
+}
+
+.icon-text-italic:before {
+  content: "\E208";
+}
+
+.icon-text-list:before {
+  content: "\E209";
+}
+
+.icon-text-list-alphabetical:before {
+  content: "\E20A";
+}
+
+.icon-text-list-alt:before {
+  content: "\E20B";
+}
+
+.icon-text-list-bullet:before {
+  content: "\E20C";
+}
+
+.icon-text-list-numbered:before {
+  content: "\E20D";
+}
+
+.icon-text-normal:before {
+  content: "\E20E";
+}
+
+.icon-text-remove-format:before {
+  content: "\E20F";
+}
+
+.icon-text-roman:before {
+  content: "\E210";
+}
+
+.icon-text-size:before {
+  content: "\E211";
+}
+
+.icon-text-strike:before {
+  content: "\E212";
+}
+
+.icon-text-styles:before {
+  content: "\E213";
+}
+
+.icon-text-sub:before {
+  content: "\E214";
+}
+
+.icon-text-super:before {
+  content: "\E215";
+}
+
+.icon-text-typeface:before {
+  content: "\E216";
+}
+
+.icon-text-underline:before {
+  content: "\E217";
+}
+
+.icon-text-unindent:before {
+  content: "\E218";
+}
+
+.icon-text-uppercase:before {
+  content: "\E219";
+}
+
+.icon-text-width:before {
+  content: "\E21A";
+}
+
+.icon-thumbs-down:before {
+  content: "\E21B";
+}
+
+.icon-thumbs-up:before {
+  content: "\E21C";
+}
+
+.icon-tint:before {
+  content: "\E21D";
+}
+
+.icon-tint-hollow:before {
+  content: "\E21E";
+}
+
+.icon-tint-none:before {
+  content: "\E21F";
+}
+
+.icon-tool-arrow:before {
+  content: "\E220";
+}
+
+.icon-tool-circle:before {
+  content: "\E221";
+}
+
+.icon-tool-crop:before {
+  content: "\E222";
+}
+
+.icon-tool-draw:before {
+  content: "\E223";
+}
+
+.icon-tool-erase:before {
+  content: "\E224";
+}
+
+.icon-tool-eyedrop:before {
+  content: "\E225";
+}
+
+.icon-tool-fill:before {
+  content: "\E226";
+}
+
+.icon-tool-hand:before {
+  content: "\E227";
+}
+
+.icon-tool-library:before {
+  content: "\E228";
+}
+
+.icon-tool-line:before {
+  content: "\E229";
+}
+
+.icon-tool-magic-wand:before {
+  content: "\E22A";
+}
+
+.icon-tool-pointer:before {
+  content: "\E22B";
+}
+
+.icon-tool-rectangle:before {
+  content: "\E22C";
+}
+
+.icon-tool-scribble:before {
+  content: "\E22D";
+}
+
+.icon-tool-stroke:before {
+  content: "\E22E";
+}
+
+.icon-tool-text:before {
+  content: "\E22F";
+}
+
+.icon-tool-text-alt:before {
+  content: "\E230";
+}
+
+.icon-tool-zone:before {
+  content: "\E231";
+}
+
+.icon-tool-zones:before {
+  content: "\E232";
+}
+
+.icon-transfer:before {
+  content: "\E233";
+}
+
+.icon-trash:before {
+  content: "\E234";
+}
+
+.icon-tree-conifer:before {
+  content: "\E235";
+}
+
+.icon-tree-deciduous:before {
+  content: "\E236";
+}
+
+.icon-triangle-0:before {
+  content: "\E237";
+}
+
+.icon-triangle-0-bottom:before {
+  content: "\E238";
+}
+
+.icon-triangle-0-left:before {
+  content: "\E239";
+}
+
+.icon-triangle-0-right:before {
+  content: "\E23A";
+}
+
+.icon-triangle-0-top:before {
+  content: "\E23B";
+}
+
+.icon-triangle-1:before {
+  content: "\E23C";
+}
+
+.icon-triangle-1-bottom:before {
+  content: "\E23D";
+}
+
+.icon-triangle-1-left:before {
+  content: "\E23E";
+}
+
+.icon-triangle-1-right:before {
+  content: "\E23F";
+}
+
+.icon-triangle-1-top:before {
+  content: "\E240";
+}
+
+.icon-triangle-2:before {
+  content: "\E241";
+}
+
+.icon-triangle-2-bottom:before {
+  content: "\E242";
+}
+
+.icon-triangle-2-left:before {
+  content: "\E243";
+}
+
+.icon-triangle-2-right:before {
+  content: "\E244";
+}
+
+.icon-triangle-2-top:before {
+  content: "\E245";
+}
+
+.icon-triangle-3:before {
+  content: "\E246";
+}
+
+.icon-triangle-3-bottom:before {
+  content: "\E247";
+}
+
+.icon-triangle-3-left:before {
+  content: "\E248";
+}
+
+.icon-triangle-3-right:before {
+  content: "\E249";
+}
+
+.icon-triangle-3-top:before {
+  content: "\E24A";
+}
+
+.icon-triangle-4:before {
+  content: "\E24B";
+}
+
+.icon-triangle-4-bottom:before {
+  content: "\E24C";
+}
+
+.icon-triangle-4-left:before {
+  content: "\E24D";
+}
+
+.icon-triangle-4-right:before {
+  content: "\E24E";
+}
+
+.icon-triangle-4-top:before {
+  content: "\E24F";
+}
+
+.icon-triangle-down:before {
+  content: "\E250";
+}
+
+.icon-triangle-left:before {
+  content: "\E251";
+}
+
+.icon-triangle-right:before {
+  content: "\E252";
+}
+
+.icon-triangle-up:before {
+  content: "\E253";
+}
+
+.icon-triangles-horizontal:before {
+  content: "\E254";
+}
+
+.icon-triangles-vertical:before {
+  content: "\E255";
+}
+
+.icon-undo:before {
+  content: "\E256";
+}
+
+.icon-ungroup:before {
+  content: "\E257";
+}
+
+.icon-uniE000:before {
+  content: "\E258";
+}
+
+.icon-upload:before {
+  content: "\E259";
+}
+
+.icon-upload-alt:before {
+  content: "\E25A";
+}
+
+.icon-user:before {
+  content: "\E25B";
+}
+
+.icon-user-add:before {
+  content: "\E25C";
+}
+
+.icon-user-group:before {
+  content: "\E25D";
+}
+
+.icon-user-group-2:before {
+  content: "\E25E";
+}
+
+.icon-user-group-3:before {
+  content: "\E25F";
+}
+
+.icon-user-group-gentlemen:before {
+  content: "\E260";
+}
+
+.icon-user-minus:before {
+  content: "\E261";
+}
+
+.icon-user-remove:before {
+  content: "\E262";
+}
+
+.icon-video-camera:before {
+  content: "\E263";
+}
+
+.icon-video-camera-off:before {
+  content: "\E264";
+}
+
+.icon-video-hd:before {
+  content: "\E265";
+}
+
+.icon-video-sd:before {
+  content: "\E266";
+}
+
+.icon-video-upload:before {
+  content: "\E267";
+}
+
+.icon-vinyl:before {
+  content: "\E268";
+}
+
+.icon-warning:before {
+  content: "\E269";
+}
+
+.icon-wifi:before {
+  content: "\E26A";
+}
+
+.icon-window:before {
+  content: "\E26B";
+}
+
+.icon-window-new:before {
+  content: "\E26C";
+}
+
+.icon-window-new-alt:before {
+  content: "\E26D";
+}
+
+.icon-zone:before {
+  content: "\E26E";
+}
+
+.icon-zone-edit:before {
+  content: "\E26F";
+}
+
+.icon-zoom:before {
+  content: "\E270";
+}
+
+.icon-zoom-in:before {
+  content: "\E271";
+}
+
+.icon-zoom-out:before {
+  content: "\E272";
+}
+
+.icon-helper-bar:before {
+  content: "\E273";
+}
+
+.icon-helper-needle:before {
+  content: "\E274";
+}
+
+.icon-helper-screen:before {
+  content: "\E275";
+}
+

BIN
public/images/border-dotted.png


BIN
public/images/cursors/crosshair.png


BIN
public/images/cursors/crosshair.psd


BIN
public/images/cursors/eyedrop.png


BIN
public/images/cursors/eyedropper.png


BIN
public/images/cursors/eyedropper.psd


BIN
public/images/cursors/pencil.png


BIN
public/images/cursors/pencil.psd


BIN
public/images/cursors/text-frame.png


BIN
public/images/cursors/text-frame.psd


+ 11 - 0
public/images/diamond.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 17.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="200px" height="217.391px" viewBox="-82 -84.196 200 217.391" enable-background="new -82 -84.196 200 217.391"
+	 xml:space="preserve">
+<polygon fill="#FFFFFF" points="-82,-17.091 -82,66.813 4.957,24.865 "/>
+<polygon fill="#FFFFFF" points="118,66.813 118,-17.126 31.043,24.848 "/>
+<polygon fill="#FFFFFF" points="18.183,11.457 108.557,-36.37 18.165,-84.196 -72.557,-36.37 "/>
+<polygon fill="#FFFFFF" points="18.183,133.196 108.557,85.37 18.165,37.543 -72.557,85.37 "/>
+</svg>

BIN
public/images/favicon.png


BIN
public/images/hourglass.gif


BIN
public/images/hue.png


BIN
public/images/huevalue.png


+ 80 - 0
public/images/ideate-char 2.svg

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="1482.219px" height="1000px" viewBox="0 0 1482.219 1000" enable-background="new 0 0 1482.219 1000" xml:space="preserve">
+<g id="Ebene_1_1_" display="none">
+	<path display="inline" fill="#489BD5" stroke="#F5F5F5" stroke-width="40" stroke-miterlimit="10" d="M1198.1,678.645
+		c-3.479-11.025-72.598-227.894-136.552-269.768c-0.203-0.271-0.405-0.546-0.612-0.802c-18.067-22.217-54.729-35.824-95.578-40.838
+		c-1.045-4.748-2.684-8.63-4.965-10.745c-1.859-1.726-4.205-3.24-6.896-4.562c0.003-0.041,0.006-0.08,0.008-0.121
+		c36.723-39.124,67.541-127.961,63.088-187.536c-1.222-16.344-4.889-29.706-10.361-40.601c0.066-1.215,0.07-2.45-0.014-3.714
+		c-1.652-25.371-13.021-38.877-30.91-45.644c-15.152-18.638-47.624-31.345-82.704-29.053c-37.004,2.416-65.922,20.8-75.777,42.672
+		c-13.468,9.271-21.062,23.464-19.609,45.67c0.166,2.544,0.632,4.948,1.37,7.219c-2.911,10.259-4.102,22.182-3.062,36.057
+		c3.205,42.908,26.104,99.075,54.188,140.044c-1.261,0.675-2.56,1.314-3.896,1.892c-3.008,1.293-5.979,1.99-8.838,2.072
+		c-10.912,0.314-17.149-8.476-17.217-8.563l-2.057,1.247c6.309,12,21.385,16.999,30.145,16.745
+		c1.396-0.041,6.106,0.383,12.015,0.445c7.17,9.104,14.549,17.076,21.922,23.503c-1.034,0.703-2,1.439-2.853,2.228
+		c-2.754,2.554-4.574,7.67-5.537,13.791c-32.92,6.519-61.259,19.111-76.448,37.792c-22.34,27.469-17.562,140.967-8.207,241.104
+		l-354.394-45.882l54.211,288.35l381.396,62.204l139.196-103.732c-1.062,3.491-1.646,6.868-1.646,10.045
+		c0,28.216,33.431,30.006,63.525,30.006c30.096-0.002,54.489-22.875,54.489-51.088c0-3.146-0.164-6.213-0.479-9.197
+		c3.776-3.562,5.916-6.071,5.916-6.071L1198.1,678.645z M1058.916,790.071c4.574-37.48,15.095-130.188,18.531-215.626
+		l55.771,108.627L1058.916,790.071z"/>
+</g>
+<path fill="#3880A8" d="M1046.778,852.009c0,0-33.005,46.755-116.854,46.755c-80.219,0-111.363-39.827-111.363-39.827
+	s-22.362-284.349-24.272-398.131c48.664-59.836,226.619-59.836,275.286,0C1030.186,535.59,1046.778,852.009,1046.778,852.009z"/>
+<path fill="#3880A8" d="M1198.225,719.612l-33.863,2.625L1005.67,465.285c0,0,46.729-13.553,63.903-4.48
+	C1106.682,531.09,1198.225,719.612,1198.225,719.612z"/>
+<path fill="#3880A8" d="M1087.183,838.573c0,0-54.277,59.114-11.254,56.479c30.657-1.876,55.026-30.527,55.026-30.527l67.27-144.91
+	l-51.396-26.331l12.353,23.799L1087.183,838.573z"/>
+<path fill="#64BEEF" d="M951.784,426.649c-8.203,16.851-52.122,18.725-63.313,0c-0.658-15.149-0.829-38.817-7.5-59.41
+	c12.312-16.646,65.005-17.396,77.313-0.75C948.775,387.084,950.896,396.168,951.784,426.649z"/>
+<path fill="#43A2E3" d="M881.562,366.489l-0.592,0.746c14.487,18.977,67.426,16.826,77.313-0.745
+	C946.162,350.098,894.881,350.575,881.562,366.489z"/>
+<polygon fill="#E8C669" points="914.678,474.499 913.486,474.499 907.35,372.135 920.812,372.135 "/>
+<polygon fill="#D1D1D1" points="907.447,1000 526.054,937.796 700.184,810.046 1087.602,866.226 "/>
+<g>
+	<polygon fill="#BDBDBD" points="580.317,923.299 705.71,832.489 1032.723,879.911 902.027,975.77 	"/>
+</g>
+<polygon opacity="0.2" fill="#43A2E3" enable-background="new    " points="907.447,1000 546.054,917.796 866.693,786.076 
+	971.688,817.574 "/>
+<polygon fill="#D1D1D1" points="907.447,1000 526.054,937.796 455.573,562.908 883.193,567.088 "/>
+<g>
+	<polygon fill="#FFFFFF" points="606.099,740.38 614.532,795.093 668.56,768.994 	"/>
+	<polygon fill="#FFFFFF" points="748.491,797.98 740.053,743.246 686.029,769.359 	"/>
+	<polygon fill="#FFFFFF" points="676.07,760.441 731.793,730.562 666.442,698.07 610.486,727.945 	"/>
+	<polygon fill="#FFFFFF" points="688.308,839.822 744.031,809.943 678.681,777.451 622.724,807.326 	"/>
+</g>
+<path fill="#64BEEF" d="M932.164,550.878c28.444,2.622,57.444,7.622,82.444-6.378c3-1,1-11,1-17c-28,3-56,2-83.896,2.457
+	L932.164,550.878z"/>
+<path fill="#64BEEF" d="M1130.116,868.584c-5.508-26.084-27.508-44.084-54.508-38.084c15-16,38-20,54-2c5,6,9,12,11,20c0,3,1,6,0,8
+	c-2,5-8,6-9.812,10.686C1130.379,868.047,1130.116,868.584,1130.116,868.584z"/>
+<circle fill="#FFFFFF" cx="971.688" cy="540.718" r="4.256"/>
+<polygon opacity="0.3" fill="#64BEEF" enable-background="new    " points="931.93,556.5 938.275,621.166 978.942,643.166 
+	1018.942,620.5 1016.263,553.933 "/>
+<path fill="#F4DA73" d="M1129.252,881.798c0.623-28.207-11.218-51.349-41.302-52.015c-30.083-0.666-76.525,42.271-77.15,70.479
+	c-0.624,28.208,32.758,30.737,62.849,31.403C1103.733,932.331,1128.628,910.003,1129.252,881.798z"/>
+<path fill="#E8C669" d="M945.007,376.335c4.116-10.04,2.2-52.529-2.905-58.859c-9.494-11.772-39.325-11.772-48.817,0
+	c-5.175,6.419-2.647,50.034,1.944,59.268C910.115,382.148,931.036,381.709,945.007,376.335z"/>
+<path fill="#F4DA73" d="M796.679,153.151c5.548,87.657,72.521,219.17,119.438,215.556c46.917-3.613,98.451-142.188,92.897-229.846
+	c-5.548-87.66-63.541-99.866-110.459-96.253C851.639,46.224,791.129,65.491,796.679,153.151z"/>
+<path opacity="0.3" fill="#DDC885" enable-background="new    " d="M806.148,207.875c2.161-0.57,4.342-1.123,6.539-1.656
+	C782.276,83.167,1005.67,82.5,992.878,196.709c-0.362,3.23,0.307,6.589,0.331,10c3.607,0.899,7.17,1.848,10.674,2.849
+	c4.471-24.989,6.477-49.48,5.132-70.697c-5.548-87.66-63.541-99.866-110.459-96.253C851.639,46.223,791.129,65.49,796.679,153.15
+	C797.746,170.03,801.106,188.723,806.148,207.875z"/>
+<path fill="#040504" d="M800.835,308.09l2.329-1.414c0.072,0.1,7.147,10.062,19.518,9.706c3.235-0.095,6.607-0.884,10.02-2.35
+	c6.596-2.837,12.315-6.925,17.363-10.534c6.789-4.852,12.648-9.04,18.868-9.218c14.089-0.407,20.259,10.047,17.557,19.384
+	C881.207,331.898,815.732,331.644,800.835,308.09z"/>
+<path fill="#040504" d="M981.224,303.056l-2.409-1.275c-0.062,0.104-6.547,10.459-18.916,10.813
+	c-3.237,0.095-6.648-0.501-10.14-1.769c-6.748-2.449-12.698-6.202-17.94-9.514c-7.058-4.452-13.15-8.295-19.37-8.114
+	c-14.089,0.405-19.648,11.198-16.409,20.364C902.364,331.463,966.714,326.624,981.224,303.056z"/>
+<path fill="#F4DA73" d="M912.526,261.812c0,11.85-10.909,23.403-24.367,23.403s-24.367-11.554-24.367-23.403H912.526z"/>
+<ellipse fill="#040504" cx="847.061" cy="200.884" rx="8.156" ry="9.624"/>
+<ellipse fill="#040504" cx="940.038" cy="200.883" rx="8.152" ry="9.621"/>
+<path fill="#F4DA73" d="M1008.556,247.511c-8.06,0.312-14.74-3-15.091-11.688c-0.354-8.684,11.674-22.297,19.73-22.608
+	c8.062-0.312,8.964,9.318,9.314,18.005C1022.863,239.905,1016.613,247.199,1008.556,247.511z"/>
+<path fill="#F4DA73" d="M805.088,252.838c8.063-0.108,14.562-3.768,14.457-12.46c-0.104-8.69-12.823-21.655-20.888-21.545
+	s-8.464,9.774-8.359,18.467C790.401,245.992,797.023,252.949,805.088,252.838z"/>
+<path fill="#E56373" d="M798.302,104.1c1.98,35.81,47.749,47.342,103.185,43.071c55.438-4.271,99.548-22.729,97.562-58.54
+	c-3.316-59.984-47.188-61.479-103.957-57.105C840.993,35.694,794.982,44.115,798.302,104.1z"/>
+<path fill="#EB6874" d="M813.698,73.452c1.825,33.041,39.354,44.045,84.702,40.554c45.353-3.493,81.343-20.161,79.517-53.202
+	c-1.827-33.04-42.643-60.333-87.989-56.839C844.579,7.46,811.869,40.413,813.698,73.452z"/>
+</svg>

+ 85 - 0
public/images/offline-char.svg

@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 17.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="1482.219px" height="1000px" viewBox="0 0 1482.219 1000" enable-background="new 0 0 1482.219 1000" xml:space="preserve">
+<ellipse fill="#67C4F9" cx="740.609" cy="562.167" rx="3" ry="7.333"/>
+<g id="Ebene_1_1_" display="none">
+	<path display="inline" fill="#489BD5" stroke="#F5F5F5" stroke-width="40" stroke-miterlimit="10" d="M1198.1,678.645
+		c-3.479-11.025-72.598-227.894-136.552-269.768c-0.203-0.271-0.405-0.546-0.612-0.802c-18.067-22.217-54.729-35.824-95.578-40.838
+		c-1.045-4.748-2.684-8.63-4.965-10.745c-1.859-1.726-4.205-3.24-6.896-4.562c0.003-0.041,0.006-0.08,0.008-0.121
+		c36.723-39.124,67.541-127.961,63.088-187.536c-1.222-16.344-4.889-29.706-10.361-40.601c0.066-1.215,0.07-2.45-0.014-3.714
+		c-1.652-25.371-13.021-38.877-30.91-45.644c-15.152-18.638-47.624-31.345-82.704-29.053c-37.004,2.416-65.922,20.8-75.777,42.672
+		c-13.468,9.271-21.062,23.464-19.609,45.67c0.166,2.544,0.632,4.948,1.37,7.219c-2.911,10.259-4.102,22.182-3.062,36.057
+		c3.205,42.908,26.104,99.075,54.188,140.044c-1.261,0.675-2.56,1.314-3.896,1.892c-3.008,1.293-5.979,1.99-8.838,2.072
+		c-10.912,0.314-17.149-8.476-17.217-8.563l-2.057,1.247c6.309,12,21.385,16.999,30.145,16.745
+		c1.396-0.041,6.106,0.383,12.015,0.445c7.17,9.104,14.549,17.076,21.922,23.503c-1.034,0.703-2,1.439-2.853,2.228
+		c-2.754,2.554-4.574,7.67-5.537,13.791c-32.92,6.519-61.259,19.111-76.448,37.792c-22.34,27.469-17.562,140.967-8.207,241.104
+		l-354.394-45.882l54.211,288.35l381.396,62.204l139.196-103.732c-1.062,3.491-1.646,6.868-1.646,10.045
+		c0,28.216,33.431,30.006,63.525,30.006c30.096-0.002,54.489-22.875,54.489-51.088c0-3.146-0.164-6.213-0.479-9.197
+		c3.776-3.562,5.916-6.071,5.916-6.071L1198.1,678.645z M1058.916,790.071c4.574-37.48,15.095-130.188,18.531-215.626
+		l55.771,108.627L1058.916,790.071z"/>
+</g>
+<path fill="#EB6874" d="M1046.778,852.009c0,0-33.005,46.755-116.854,46.755c-80.219,0-111.363-39.827-111.363-39.827
+	s-22.362-284.349-24.272-398.131c48.664-59.836,226.619-59.836,275.286,0C1030.186,535.59,1046.778,852.009,1046.778,852.009z"/>
+<path fill="#EB6874" d="M924.76,414.256c0.692,18.729-37.178,41.051-55.864,29.795c-7.71-13.057-18.999-33.86-34.576-48.891
+	c3.03-20.482,49.17-45.941,67.864-37.045C903.486,380.762,909.632,387.779,924.76,414.256z"/>
+<path fill="#E56373" d="M834.488,394.22l-0.171,0.937c21.713,9.927,67.411-16.884,67.866-37.041
+	C883.774,349.357,838.751,373.911,834.488,394.22z"/>
+<polygon fill="#E8C669" points="914.538,473.938 913.486,474.499 859.899,387.066 871.778,380.731 "/>
+<polygon fill="#D1D1D1" points="907.447,1000 526.054,937.796 700.184,810.046 1087.602,866.226 "/>
+<g>
+	<polygon fill="#BDBDBD" points="580.317,923.299 705.71,832.489 1032.723,879.911 902.027,975.77 	"/>
+</g>
+<polygon opacity="0.2" fill="#43A2E3" enable-background="new    " points="907.447,1000 546.054,917.796 866.693,834.076 
+	971.688,849.574 "/>
+<path fill="#EB6874" d="M932.164,550.878c28.444,2.622,57.444,7.622,82.444-6.378c3-1,1-11,1-17c-28,3-56,2-83.896,2.457
+	L932.164,550.878z"/>
+<circle fill="#FFFFFF" cx="971.688" cy="540.718" r="4.256"/>
+<polygon fill="#EB6874" points="931.93,556.5 938.275,621.166 978.942,643.166 1018.942,620.5 1016.263,553.933 "/>
+<path fill="#E8C669" d="M895.102,373.051c-1.093-10.796-22.779-47.384-30.262-50.567c-13.917-5.919-40.238,8.119-43.073,22.973
+	c-1.545,8.099,21.21,45.393,29.607,51.38C867.051,394.6,885.304,384.367,895.102,373.051z"/>
+<path fill="#F4DA73" d="M621.968,241.158c-22.896,147.357,233.24,167.546,268.186,120.351
+	c-8.544-29.009-43.544-94.009-78.605-198.818"/>
+<path fill="#040504" d="M654.749,431.138l0.828-3.298c0.152,0.026,15.17,2.667,25.842-8.498c2.789-2.921,5.091-6.579,6.836-10.874
+	c3.369-8.303,4.862-16.948,6.179-24.579c1.773-10.262,3.303-19.118,8.669-24.731c12.153-12.717,26.799-8.845,32.587,1.815
+	C746.99,381.795,688.632,438.987,654.749,431.138z"/>
+<path fill="#040504" d="M823.866,401.452l-2.239-2.561c-0.119,0.094-12.296,9.27-26.87,4.165c-3.815-1.333-7.525-3.549-11.029-6.585
+	c-6.773-5.864-12.033-12.892-16.666-19.091c-6.24-8.337-11.627-15.533-18.957-18.097c-16.602-5.812-27.895,4.288-28.208,16.416
+	C719.283,399.386,796.442,422.449,823.866,401.452z"/>
+<path fill="#F4DA73" d="M744.807,351.491c-2.322,14.605-58.032,49.185-74.618,46.547c-16.587-2.638,24.712-38.999,27.035-53.603
+	L744.807,351.491z"/>
+<ellipse transform="matrix(-0.9966 0.0826 -0.0826 -0.9966 1391.5819 619.5657)" fill="#040504" cx="682.969" cy="338.581" rx="9.088" ry="6.447"/>
+<ellipse transform="matrix(-0.9966 0.0826 -0.0826 -0.9966 1505.1034 595.0516)" fill="#040504" cx="740.238" cy="328.673" rx="9.088" ry="6.447"/>
+<path fill="#F4DA73" d="M861.254,263.038c-6.295,7.855-14.878,11.447-23.357,4.675c-8.478-6.765-11.482-29.235-5.189-37.086
+	c6.296-7.857,16.114-0.835,24.592,5.936C865.778,243.33,867.547,255.186,861.254,263.038z"/>
+<path fill="#040504" d="M544.484,239.217c21.773,74.776,97.485,81.792,179.633,51.824c82.151-29.969,139.627-85.575,117.844-160.351
+	C805.49,5.435,737.924,18.87,653.801,49.559C573.634,78.804,508.007,113.961,544.484,239.217z"/>
+<path fill="#040504" d="M684.237,146.584c1.611,5.362,7.459,5.829,13.841,3.635c6.383-2.195,10.88-6.219,9.27-11.581
+	c-1.611-5.362-8.597-8.387-14.978-6.192C685.987,134.64,682.626,141.222,684.237,146.584z"/>
+<path fill="#E56373" d="M972.183,894.5l51.385,15.322l46.007-449.016c0,0-22.847-6.793-34.807,8.511
+	C959.04,493.447,972.183,894.5,972.183,894.5z"/>
+<path fill="#E56373" d="M884.897,605.923l87.106,300.002l57.698,2.416l-21.176-16.449L924.609,581.5L884.897,605.923z"/>
+<path fill="#EB6874" d="M551.41,611.453c19.156-18.541,27.278-24.86,7.833-44.53c21.528,4.189,37.152,21.535,30.423,44.659
+	c-2.42,7.426-5.372,14.005-11.082,19.955c-2.54,1.596-4.548,4.039-6.774,4.256c-5.298,0.967-13.923-25.467-18.854-24.508
+	C552.005,611.39,551.41,611.453,551.41,611.453z"/>
+<path fill="#E8C669" d="M920.909,606.86c9.936-9.53,8.846-26.124-13.438-47.732c-22.284-21.607-87.118-26.998-97.056-17.469
+	c-9.937,9.53,29.863,21.716,52.153,43.329C884.852,606.596,910.974,616.388,920.909,606.86z"/>
+<polygon fill="#D1D1D1" points="907.447,1000 526.054,937.796 455.573,562.908 883.193,567.088 "/>
+<g>
+	<polygon fill="#FFFFFF" points="606.099,740.38 614.532,795.093 668.56,768.994 	"/>
+	<polygon fill="#FFFFFF" points="748.491,797.98 740.053,743.246 686.029,769.359 	"/>
+	<polygon fill="#FFFFFF" points="676.07,760.441 731.793,730.562 666.442,698.07 610.486,727.945 	"/>
+	<polygon fill="#FFFFFF" points="688.308,839.822 744.031,809.943 678.681,777.451 622.724,807.326 	"/>
+</g>
+<path fill="#E8C669" d="M840.661,552.027c-5.182-11.489-15.091-18.191-28.998-11.823c-13.907,6.368-20.231,50.838-15.05,62.328
+	c5.182,11.49,14.337-11.802,28.248-18.172C838.768,577.993,845.842,563.515,840.661,552.027z"/>
+<path fill="#E8C669" d="M857.661,558.027c-5.182-11.489-15.091-18.191-28.998-11.823c-13.907,6.368-20.231,50.838-15.05,62.328
+	c5.182,11.49,20.614-35.997,34.525-42.366C856.739,567.637,862.842,569.515,857.661,558.027z"/>
+<ellipse fill="#67C4F9" cx="740.609" cy="345.167" rx="3" ry="7.333"/>
+<ellipse fill="#67C4F9" cx="740.609" cy="385.167" rx="3" ry="7.333"/>
+<ellipse fill="#67C4F9" cx="740.609" cy="455.167" rx="3" ry="7.333"/>
+<ellipse fill="#67C4F9" cx="680.609" cy="355.167" rx="3" ry="7.333"/>
+<ellipse fill="#67C4F9" cx="680.609" cy="465.167" rx="3" ry="7.333"/>
+<ellipse fill="#67C4F9" cx="680.609" cy="545.167" rx="3" ry="7.333"/>
+</svg>

BIN
public/images/opacity-grid.png


BIN
public/images/opacity-strip.png


BIN
public/images/placeholder-4x3.gif


BIN
public/images/sd5-hero2-compressed.jpg


BIN
public/images/sd5-keyvisual-compressed.jpg


+ 124 - 0
public/images/sd5-logo-inverted.svg

@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="185"
+   height="45"
+   viewBox="0 0 185 45"
+   id="svg7843"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="sd5-logo-inverted.svg">
+  <defs
+     id="defs7845" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="3.959798"
+     inkscape:cx="148.57143"
+     inkscape:cy="76.667698"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     inkscape:window-width="2560"
+     inkscape:window-height="1492"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     units="px" />
+  <metadata
+     id="metadata7848">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1007.3622)">
+    <rect
+       y="1007.3622"
+       x="4.9999999e-06"
+       height="45.883873"
+       width="185.31001"
+       id="rect6409-6-8-8-7-6-4"
+       style="opacity:1;fill:#1a1a1a;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-linecap:square;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+    <g
+       id="g7809"
+       transform="matrix(0.70980573,0,0,0.70980573,-269.60224,2203.0642)">
+      <g
+         id="flowRoot3757-07-1-0-5-4-5-0-3-1-0-0-5-0"
+         style="font-style:normal;font-weight:normal;font-size:10px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none"
+         transform="translate(275.2292,-1717.1004)">
+        <path
+           id="path7771"
+           style="letter-spacing:-2px;fill:#ffffff"
+           d="m 180.84516,69.158646 q 0,2.736 -1.98,4.392 -1.98,1.62 -5.076,1.62 -2.088,0 -4.356,-0.756 -2.232,-0.756 -3.96,-2.124 l 1.944,-3.132 q 3.348,2.52 6.48,2.52 1.368,0 2.16,-0.612 0.792,-0.648 0.792,-1.62 0,-1.44 -3.816,-2.592 -0.288,-0.108 -0.432,-0.144 -5.976,-1.62 -5.976,-5.4 0,-2.664 2.052,-4.248 2.088,-1.62 5.4,-1.62 3.312,0 6.264,1.944 l -1.512,2.988 q -2.34,-1.548 -5.076,-1.548 -1.44,0 -2.34,0.54 -0.9,0.504 -0.9,1.548 0,0.936 0.936,1.368 0.648,0.324 1.8,0.612 1.188,0.288 2.088,0.576 0.9,0.288 1.728,0.648 0.864,0.36 1.836,1.008 1.944,1.296 1.944,4.032 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7773"
+           style="letter-spacing:-2px;fill:#ffffff"
+           d="m 193.67209,55.442646 q 3.852,0 6.588,2.664 2.736,2.664 2.736,7.092 0,4.428 -2.736,7.2 -2.7,2.772 -6.336,2.772 -3.6,0 -6.48,-3.168 l 0,9.864 -4.032,0 0,-26.136 4.032,0 0,3.348 q 2.376,-3.636 6.228,-3.636 z m -6.3,9.9 q 0,2.736 1.656,4.5 1.656,1.728 4.032,1.728 2.376,0 4.14,-1.728 1.764,-1.728 1.764,-4.464 0,-2.772 -1.728,-4.572 -1.728,-1.836 -4.14,-1.836 -2.412,0 -4.068,1.836 -1.656,1.8 -1.656,4.536 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7775"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#ffffff"
+           d="m 213.41159,75.170646 q -3.636,0 -6.336,-2.736 -2.7,-2.736 -2.7,-7.2 0,-4.464 2.772,-7.128 2.772,-2.664 6.552,-2.664 3.78,0 6.228,3.6 l 0,-3.312 4.032,0 0,19.152 -4.032,0 0,-3.132 q -1.152,1.656 -2.952,2.556 -1.764,0.864 -3.564,0.864 z m -5.004,-9.756 q 0,2.736 1.764,4.464 1.764,1.728 4.14,1.728 2.376,0 4.032,-1.728 1.656,-1.764 1.656,-4.5 0,-2.736 -1.656,-4.572 -1.656,-1.836 -4.068,-1.836 -2.412,0 -4.14,1.836 -1.728,1.836 -1.728,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7777"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#ffffff"
+           d="m 235.28309,75.170646 q -3.996,0 -6.876,-2.7 -2.88,-2.736 -2.88,-7.128 0,-4.392 2.988,-7.128 3.024,-2.772 7.344,-2.772 4.32,0 7.308,2.988 l -2.376,2.916 q -2.484,-2.16 -5.004,-2.16 -2.52,0 -4.392,1.692 -1.836,1.656 -1.836,4.32 0,2.628 1.836,4.464 1.836,1.836 4.5,1.836 2.7,0 5.076,-2.448 l 2.376,2.592 q -3.492,3.528 -8.064,3.528 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7779"
+           style="letter-spacing:-2px;fill:#ffffff"
+           d="m 253.24128,55.442646 q 3.924,0 6.516,2.196 2.592,2.16 2.628,5.652 l 0.036,0.072 -13.968,5.868 q 1.872,2.304 5.076,2.304 3.24,0 5.328,-2.52 l 2.376,2.628 q -3.42,3.528 -8.064,3.528 -4.068,0 -6.984,-2.7 -2.916,-2.7 -2.916,-7.128 0,-3.06 1.512,-5.364 1.512,-2.34 3.744,-3.42 2.232,-1.116 4.716,-1.116 z m -6.084,9.972 q 0,0.468 0.036,0.684 l 10.62,-4.572 q -1.224,-2.376 -4.356,-2.376 -2.376,0 -4.356,1.656 -1.944,1.656 -1.944,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7781"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#ffffff"
+           d="m 272.34909,75.170646 q -3.636,0 -6.336,-2.736 -2.7,-2.736 -2.7,-7.2 0,-4.464 2.772,-7.128 2.772,-2.664 6.588,-2.664 3.852,0 6.192,3.24 l 0,-10.512 4.032,0 0,26.712 -4.032,0 0,-2.88 q -2.232,3.168 -6.516,3.168 z m -5.004,-9.756 q 0,2.736 1.764,4.464 1.764,1.728 4.14,1.728 2.376,0 4.032,-1.728 1.656,-1.764 1.656,-4.5 0,-2.736 -1.656,-4.572 -1.656,-1.836 -4.068,-1.836 -2.412,0 -4.14,1.836 -1.728,1.836 -1.728,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7783"
+           style="letter-spacing:-2px;fill:#ffffff"
+           d="m 294.43659,55.442646 q 3.924,0 6.516,2.196 2.592,2.16 2.628,5.652 l 0.036,0.072 -13.968,5.868 q 1.872,2.304 5.076,2.304 3.24,0 5.328,-2.52 l 2.376,2.628 q -3.42,3.528 -8.064,3.528 -4.068,0 -6.984,-2.7 -2.916,-2.7 -2.916,-7.128 0,-3.06 1.512,-5.364 1.512,-2.34 3.744,-3.42 2.232,-1.116 4.716,-1.116 z m -6.084,9.972 q 0,0.468 0.036,0.684 l 10.62,-4.572 q -1.224,-2.376 -4.356,-2.376 -2.376,0 -4.356,1.656 -1.944,1.656 -1.944,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7785"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#ffffff"
+           d="m 314.22841,75.170646 q -3.996,0 -6.876,-2.7 -2.88,-2.736 -2.88,-7.128 0,-4.392 2.988,-7.128 3.024,-2.772 7.344,-2.772 4.32,0 7.308,2.988 l -2.376,2.916 q -2.484,-2.16 -5.004,-2.16 -2.52,0 -4.392,1.692 -1.836,1.656 -1.836,4.32 0,2.628 1.836,4.464 1.836,1.836 4.5,1.836 2.7,0 5.076,-2.448 l 2.376,2.592 q -3.492,3.528 -8.064,3.528 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7787"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#ffffff"
+           d="m 327.50659,74.882646 -4.032,0 0,-26.712 4.032,0 0,15.696 7.776,-8.136 5.184,0 -7.272,7.596 7.812,11.556 -4.896,0 -5.688,-8.388 -2.916,2.952 0,5.436 z"
+           inkscape:connector-curvature="0" />
+      </g>
+      <path
+         style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:3;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         d="m 419.30039,-1669.22 c -0.83723,0 -14.08139,7.648 -14.5,8.3731 -0.41861,0.725 -0.41861,16.0191 0,16.7441 0.41861,0.7251 13.66277,8.3731 14.5,8.3731 0.83722,0 14.08334,-7.648 14.50195,-8.3731 0.41861,-0.725 0.41861,-16.0191 0,-16.7441 -0.41861,-0.7251 -13.66473,-8.3731 -14.50195,-8.3731 z m 11.14843,8.2676 a 14.016884,14.016884 0 0 1 1.75391,3.0332 l -9.39648,5.4395 9.42187,5.4238 a 14.016884,14.016884 0 0 1 -1.75977,3.0273 l -11.1621,-6.4258 -11.15235,6.4571 a 14.016884,14.016884 0 0 1 -1.75195,-3.0313 l 9.39844,-5.4433 -9.42188,-5.4239 a 14.016884,14.016884 0 0 1 1.76172,-3.0254 l 11.16016,6.4239 11.14843,-6.4551 z"
+         id="path4358-4-6-6-5-8-4-8-2-5-6-8-6-3-9-4-7-5-6-2-4-0-9-6-5-2-5-9-5"
+         inkscape:connector-curvature="0" />
+    </g>
+  </g>
+</svg>

BIN
public/images/sd5-logo.png


+ 118 - 0
public/images/sd5-logo.svg

@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="185"
+   height="45"
+   viewBox="0 0 185 45"
+   id="svg7843"
+   version="1.1"
+   inkscape:version="0.91 r13725"
+   sodipodi:docname="sd5-logo.svg">
+  <defs
+     id="defs7845" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="3.959798"
+     inkscape:cx="65.459885"
+     inkscape:cy="43.855277"
+     inkscape:document-units="px"
+     inkscape:current-layer="g7809"
+     showgrid="false"
+     inkscape:window-width="2560"
+     inkscape:window-height="1492"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     units="px" />
+  <metadata
+     id="metadata7848">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1007.3622)">
+    <g
+       id="g7809"
+       transform="matrix(0.70980573,0,0,0.70980573,-269.60224,2203.0642)"
+       style="fill:#000000">
+      <g
+         id="flowRoot3757-07-1-0-5-4-5-0-3-1-0-0-5-0"
+         style="font-style:normal;font-weight:normal;font-size:10px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"
+         transform="translate(275.2292,-1717.1004)">
+        <path
+           id="path7771"
+           style="letter-spacing:-2px;fill:#000000"
+           d="m 180.84516,69.158646 q 0,2.736 -1.98,4.392 -1.98,1.62 -5.076,1.62 -2.088,0 -4.356,-0.756 -2.232,-0.756 -3.96,-2.124 l 1.944,-3.132 q 3.348,2.52 6.48,2.52 1.368,0 2.16,-0.612 0.792,-0.648 0.792,-1.62 0,-1.44 -3.816,-2.592 -0.288,-0.108 -0.432,-0.144 -5.976,-1.62 -5.976,-5.4 0,-2.664 2.052,-4.248 2.088,-1.62 5.4,-1.62 3.312,0 6.264,1.944 l -1.512,2.988 q -2.34,-1.548 -5.076,-1.548 -1.44,0 -2.34,0.54 -0.9,0.504 -0.9,1.548 0,0.936 0.936,1.368 0.648,0.324 1.8,0.612 1.188,0.288 2.088,0.576 0.9,0.288 1.728,0.648 0.864,0.36 1.836,1.008 1.944,1.296 1.944,4.032 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7773"
+           style="letter-spacing:-2px;fill:#000000"
+           d="m 193.67209,55.442646 q 3.852,0 6.588,2.664 2.736,2.664 2.736,7.092 0,4.428 -2.736,7.2 -2.7,2.772 -6.336,2.772 -3.6,0 -6.48,-3.168 l 0,9.864 -4.032,0 0,-26.136 4.032,0 0,3.348 q 2.376,-3.636 6.228,-3.636 z m -6.3,9.9 q 0,2.736 1.656,4.5 1.656,1.728 4.032,1.728 2.376,0 4.14,-1.728 1.764,-1.728 1.764,-4.464 0,-2.772 -1.728,-4.572 -1.728,-1.836 -4.14,-1.836 -2.412,0 -4.068,1.836 -1.656,1.8 -1.656,4.536 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7775"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#000000"
+           d="m 213.41159,75.170646 q -3.636,0 -6.336,-2.736 -2.7,-2.736 -2.7,-7.2 0,-4.464 2.772,-7.128 2.772,-2.664 6.552,-2.664 3.78,0 6.228,3.6 l 0,-3.312 4.032,0 0,19.152 -4.032,0 0,-3.132 q -1.152,1.656 -2.952,2.556 -1.764,0.864 -3.564,0.864 z m -5.004,-9.756 q 0,2.736 1.764,4.464 1.764,1.728 4.14,1.728 2.376,0 4.032,-1.728 1.656,-1.764 1.656,-4.5 0,-2.736 -1.656,-4.572 -1.656,-1.836 -4.068,-1.836 -2.412,0 -4.14,1.836 -1.728,1.836 -1.728,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7777"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#000000"
+           d="m 235.28309,75.170646 q -3.996,0 -6.876,-2.7 -2.88,-2.736 -2.88,-7.128 0,-4.392 2.988,-7.128 3.024,-2.772 7.344,-2.772 4.32,0 7.308,2.988 l -2.376,2.916 q -2.484,-2.16 -5.004,-2.16 -2.52,0 -4.392,1.692 -1.836,1.656 -1.836,4.32 0,2.628 1.836,4.464 1.836,1.836 4.5,1.836 2.7,0 5.076,-2.448 l 2.376,2.592 q -3.492,3.528 -8.064,3.528 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7779"
+           style="letter-spacing:-2px;fill:#000000"
+           d="m 253.24128,55.442646 q 3.924,0 6.516,2.196 2.592,2.16 2.628,5.652 l 0.036,0.072 -13.968,5.868 q 1.872,2.304 5.076,2.304 3.24,0 5.328,-2.52 l 2.376,2.628 q -3.42,3.528 -8.064,3.528 -4.068,0 -6.984,-2.7 -2.916,-2.7 -2.916,-7.128 0,-3.06 1.512,-5.364 1.512,-2.34 3.744,-3.42 2.232,-1.116 4.716,-1.116 z m -6.084,9.972 q 0,0.468 0.036,0.684 l 10.62,-4.572 q -1.224,-2.376 -4.356,-2.376 -2.376,0 -4.356,1.656 -1.944,1.656 -1.944,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7781"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#000000"
+           d="m 272.34909,75.170646 q -3.636,0 -6.336,-2.736 -2.7,-2.736 -2.7,-7.2 0,-4.464 2.772,-7.128 2.772,-2.664 6.588,-2.664 3.852,0 6.192,3.24 l 0,-10.512 4.032,0 0,26.712 -4.032,0 0,-2.88 q -2.232,3.168 -6.516,3.168 z m -5.004,-9.756 q 0,2.736 1.764,4.464 1.764,1.728 4.14,1.728 2.376,0 4.032,-1.728 1.656,-1.764 1.656,-4.5 0,-2.736 -1.656,-4.572 -1.656,-1.836 -4.068,-1.836 -2.412,0 -4.14,1.836 -1.728,1.836 -1.728,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7783"
+           style="letter-spacing:-2px;fill:#000000"
+           d="m 294.43659,55.442646 q 3.924,0 6.516,2.196 2.592,2.16 2.628,5.652 l 0.036,0.072 -13.968,5.868 q 1.872,2.304 5.076,2.304 3.24,0 5.328,-2.52 l 2.376,2.628 q -3.42,3.528 -8.064,3.528 -4.068,0 -6.984,-2.7 -2.916,-2.7 -2.916,-7.128 0,-3.06 1.512,-5.364 1.512,-2.34 3.744,-3.42 2.232,-1.116 4.716,-1.116 z m -6.084,9.972 q 0,0.468 0.036,0.684 l 10.62,-4.572 q -1.224,-2.376 -4.356,-2.376 -2.376,0 -4.356,1.656 -1.944,1.656 -1.944,4.608 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7785"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#000000"
+           d="m 314.22841,75.170646 q -3.996,0 -6.876,-2.7 -2.88,-2.736 -2.88,-7.128 0,-4.392 2.988,-7.128 3.024,-2.772 7.344,-2.772 4.32,0 7.308,2.988 l -2.376,2.916 q -2.484,-2.16 -5.004,-2.16 -2.52,0 -4.392,1.692 -1.836,1.656 -1.836,4.32 0,2.628 1.836,4.464 1.836,1.836 4.5,1.836 2.7,0 5.076,-2.448 l 2.376,2.592 q -3.492,3.528 -8.064,3.528 z"
+           inkscape:connector-curvature="0" />
+        <path
+           id="path7787"
+           style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:36px;font-family:'Montserrat Alternates';-inkscape-font-specification:'Montserrat Alternates';letter-spacing:-3px;fill:#000000"
+           d="m 327.50659,74.882646 -4.032,0 0,-26.712 4.032,0 0,15.696 7.776,-8.136 5.184,0 -7.272,7.596 7.812,11.556 -4.896,0 -5.688,-8.388 -2.916,2.952 0,5.436 z"
+           inkscape:connector-curvature="0" />
+      </g>
+      <path
+         style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         d="m 417.74911,-1670.7774 -14.58027,8.4162 14.57783,8.3919 14.5364,-8.4187 -14.53396,-8.3894 z m 15.60884,10.2028 -13.50296,7.819 13.50296,7.7727 0,-15.5917 z m -31.21768,0.049 0,15.5917 13.50296,-7.819 -13.50296,-7.7727 z m 15.61128,8.9864 -14.5364,8.4187 14.53396,8.3894 14.58027,-8.4162 -14.57783,-8.3919 z"
+         id="path4358-4-6-6-5-8-4-8-2-5-6-8"
+         inkscape:connector-curvature="0" />
+    </g>
+  </g>
+</svg>

+ 50 - 0
public/images/spacedeck-logo.svg

@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 17.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="118.807px" height="26.558px" viewBox="13 11.299 118.807 26.558" enable-background="new 13 11.299 118.807 26.558"
+	 xml:space="preserve">
+<path fill="#FFFFFF" d="M52.333,22.961c-2.275-0.54-2.814-0.942-2.814-1.853v-0.035c0-0.774,0.708-1.397,1.921-1.397
+	c1.08,0,2.143,0.422,3.203,1.214l1.113-1.566c-1.197-0.963-2.562-1.483-4.281-1.483c-2.342,0-4.027,1.398-4.027,3.42v0.033
+	c0,2.175,1.416,2.917,3.927,3.522c2.189,0.508,2.663,0.963,2.663,1.807v0.03c0,0.879-0.811,1.483-2.091,1.483
+	c-1.448,0-2.596-0.556-3.724-1.536l-1.246,1.488c1.416,1.26,3.118,1.884,4.921,1.884c2.476,0,4.214-1.312,4.214-3.522v-0.032
+	C56.111,24.462,54.827,23.567,52.333,22.961z"/>
+<path fill="#FFFFFF" d="M62.313,20.72c-1.449,0-2.376,0.74-3.033,1.669v-1.482h-2.039V32.5h2.039v-4.078
+	c0.623,0.825,1.549,1.568,3.033,1.568c2.106,0,4.129-1.67,4.129-4.618v-0.036C66.443,22.372,64.403,20.72,62.313,20.72z
+	 M64.37,25.371c0,1.753-1.146,2.848-2.545,2.848c-1.383,0-2.597-1.144-2.597-2.848v-0.036c0-1.699,1.214-2.846,2.597-2.846
+	c1.384,0,2.545,1.129,2.545,2.846V25.371z"/>
+<path fill="#FFFFFF" d="M71.325,20.788c-1.417,0-2.378,0.284-3.355,0.724l0.541,1.603c0.81-0.337,1.549-0.557,2.543-0.557
+	c1.434,0,2.209,0.675,2.209,1.939v0.216c-0.689-0.216-1.399-0.37-2.395-0.37c-2.171,0-3.672,0.944-3.672,2.866v0.034
+	c0,1.787,1.484,2.748,3.15,2.748c1.366,0,2.292-0.574,2.9-1.301v1.115h1.99v-5.274c0-1.164-0.305-2.109-0.961-2.748
+	C73.65,21.141,72.655,20.788,71.325,20.788z M73.297,26.6c0,1.131-1.027,1.906-2.377,1.906c-0.978,0-1.736-0.489-1.736-1.35v-0.032
+	c0-0.927,0.777-1.484,2.09-1.484c0.81,0,1.501,0.152,2.022,0.355L73.297,26.6L73.297,26.6z"/>
+<path fill="#FFFFFF" d="M81.052,28.219c-1.569,0-2.663-1.262-2.663-2.848v-0.034c0-1.55,1.094-2.83,2.578-2.83
+	c1.014,0,1.668,0.454,2.293,1.111l1.279-1.364c-0.842-0.91-1.887-1.534-3.557-1.534c-2.681,0-4.635,2.122-4.635,4.651v0.031
+	c0,2.529,1.957,4.604,4.604,4.604c1.734,0,2.762-0.691,3.639-1.668l-1.23-1.215C82.721,27.783,82.014,28.219,81.052,28.219z"/>
+<path fill="#FFFFFF" d="M89.303,20.72c-2.546,0-4.349,2.09-4.349,4.636v0.032c0,2.748,1.987,4.619,4.567,4.619
+	c1.636,0,2.797-0.66,3.643-1.668l-1.197-1.062c-0.711,0.692-1.418,1.062-2.414,1.062c-1.312,0-2.342-0.809-2.562-2.257h6.541
+	c0.016-0.206,0.031-0.391,0.031-0.576C93.566,22.945,92.134,20.72,89.303,20.72z M86.977,24.731
+	c0.187-1.384,1.062-2.344,2.309-2.344c1.348,0,2.123,1.027,2.258,2.344H86.977z"/>
+<path fill="#FFFFFF" d="M101.723,22.287c-0.643-0.844-1.568-1.567-3.035-1.567c-2.123,0-4.129,1.667-4.129,4.615v0.036
+	c0,2.949,2.039,4.619,4.129,4.619c1.448,0,2.375-0.742,3.035-1.669v1.482h2.037V17.5h-2.037V22.287z M101.754,25.371
+	c0,1.704-1.211,2.848-2.576,2.848c-1.386,0-2.562-1.13-2.562-2.848v-0.036c0-1.768,1.163-2.846,2.562-2.846
+	c1.365,0,2.576,1.129,2.576,2.846V25.371z"/>
+<path fill="#FFFFFF" d="M109.105,20.72c-2.543,0-4.347,2.09-4.347,4.636v0.032c0,2.748,1.987,4.619,4.568,4.619
+	c1.634,0,2.799-0.66,3.64-1.668l-1.195-1.062c-0.709,0.692-1.416,1.062-2.414,1.062c-1.312,0-2.342-0.809-2.56-2.257h6.539
+	c0.015-0.206,0.032-0.391,0.032-0.576C113.371,22.945,111.939,20.72,109.105,20.72z M106.781,24.731
+	c0.187-1.384,1.062-2.344,2.309-2.344c1.35,0,2.121,1.027,2.26,2.344H106.781z"/>
+<path fill="#FFFFFF" d="M119.034,28.219c-1.57,0-2.665-1.262-2.665-2.848v-0.034c0-1.55,1.095-2.83,2.579-2.83
+	c1.011,0,1.669,0.454,2.292,1.111l1.281-1.364c-0.842-0.91-1.889-1.534-3.556-1.534c-2.681,0-4.635,2.122-4.635,4.651v0.031
+	c0,2.529,1.955,4.604,4.603,4.604c1.733,0,2.762-0.691,3.639-1.668l-1.231-1.215C120.702,27.783,119.991,28.219,119.034,28.219z"/>
+<polygon fill="#FFFFFF" points="128.27,24.529 131.807,20.905 129.33,20.905 125.637,24.849 125.637,17.5 123.6,17.5 123.6,29.803 
+	125.637,29.803 125.637,27.258 126.902,25.945 129.532,29.805 131.926,29.805 "/>
+<g>
+	<polygon fill="#FFFFFF" points="36,19.009 26.355,24.578 36,30.147 	"/>
+	<polygon fill="#FFFFFF" points="24.5,23.507 35.073,17.403 24.5,11.299 13.927,17.403 	"/>
+	<polygon fill="#FFFFFF" points="13,17.939 13,17.939 13,17.939 	"/>
+	<polygon fill="#FFFFFF" points="36,17.939 36,17.939 36,17.938 	"/>
+	<polygon fill="#FFFFFF" points="36,31.218 36,31.218 36,31.218 	"/>
+	<polygon fill="#FFFFFF" points="24.5,25.649 13.927,31.753 24.5,37.857 35.073,31.753 	"/>
+	<polygon fill="#FFFFFF" points="22.645,24.578 13,19.009 13,30.147 	"/>
+</g>
+</svg>

BIN
public/images/spinner.gif


BIN
public/images/spinner2.gif


+ 7 - 0
public/images/triangle.svg

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 17.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="780px" height="60px" viewBox="0 0 780 60" enable-background="new 0 0 780 60" xml:space="preserve">
+<polygon fill="#303030" points="780,0 779.932,60 0,59.458 "/>
+</svg>

+ 339 - 0
public/javascripts/backend.js

@@ -0,0 +1,339 @@
+var api_endpoint = ENV.apiEndpoint;
+var api_socket_endpoint = ENV.websocketsEndpoint;
+
+var api_token = null;
+var websocket = null;
+var channel_id = null;
+var space_auth = null;
+
+function load_resource(method, path, data, on_success, on_error, on_progress) {
+  var req = new XMLHttpRequest();
+  req.onload = function(evt,b,c) {
+    if (req.status>=200 && req.status<=299) {
+      var parsed = null;
+      
+      try {
+        var parsed = JSON.parse(req.response);
+      } catch(e) {};
+
+      if (data && parsed && parsed._id) {
+        // mutate the local object and update its _id
+        data._id = parsed._id;
+      }
+      if (on_success) {
+        on_success(parsed,req);
+      }
+    } else {
+      if (on_error) {
+        on_error(req);
+      }
+    }
+  };
+
+  req.onerror = function(err) {
+    console.log(err,err.target);
+    // window._spacedeck_location_change is a flag set by redirect / reload functions
+    if (!window._spacedeck_location_change) {
+      if (window.spacedeck && window.spacedeck.active_space) {
+        window.spacedeck.offline = true;
+      } else {
+        alert("Could not connect to Spacedeck. Please reconnect and try again.");
+      }
+    }
+    if (on_error) on_error(req);
+  }
+
+  req.withCredentials = true;
+
+  req.open(method, api_endpoint+"/api"+path, true);
+
+  if (api_token) {
+    req.setRequestHeader("X-Spacedeck-Auth", api_token);
+  }
+
+  if (space_auth) {
+    console.log("set space auth", space_auth);
+    req.setRequestHeader("X-Spacedeck-Space-Auth", space_auth);
+  }
+
+  if (channel_id) {
+    req.setRequestHeader("X-Spacedeck-Channel", channel_id);
+  }
+  if (csrf_token) {
+    req.setRequestHeader("X-csrf-token", csrf_token);
+  }
+
+  try {
+    if (data) {
+      if (data.toString() == "[object File]") {
+        req.setRequestHeader("Content-type", data.type);
+        req.setRequestHeader("Accepts", "application/json");
+        req.upload.onprogress = function(e) {
+          console.log("upload progress: ",e.loaded,e.total);
+          if (on_progress) on_progress(e);
+        }
+        req.send(data);
+      } else {
+        req.setRequestHeader("Content-type", "application/json");
+        req.send(JSON.stringify(data));
+      }
+    } else {
+      req.send();
+    }
+  } catch (e) {
+    if (on_error) {
+      on_error(req, e);
+    } else {
+      throw(e);
+    }
+  }
+}
+
+
+function get_resource(path, on_success, on_error, on_progress) {
+  load_resource("get", path, null, on_success, on_error, on_progress);
+}
+
+function load_profile(name, on_success, on_error) {
+  load_resource("get", "/users/slug?slug="+name, null, on_success, on_error);
+}
+
+function load_current_user(on_success, on_error) {
+  load_resource("get", "/users/current", null, on_success, on_error);
+}
+
+function load_space(id, on_success, on_error) {
+  if (!id || id=="undefined") {
+    console.error("load_space id:", id);
+    return;
+  }
+  var url = "/spaces/"+id;
+  load_resource("get", url, null, function(space, req) {
+    var role = req.getResponseHeader("x-spacedeck-space-role");
+    on_success(space, role);
+  }, on_error);
+}
+
+function load_space_path(id, on_success, on_error) {
+  var url = "/spaces/"+id+"/path";
+  load_resource("get", url, null, function(space, req) {
+    on_success(space);
+  }, on_error);
+}
+
+function load_spaces(id, is_home, on_success, on_error) {
+  if (!id || id=="undefined") {
+    console.error("load_spaces id:", id);
+    return;
+  }
+
+  var q = "?parent_space_id="+id;
+  load_resource("get", "/spaces"+q, null, function(spaces) {
+    on_success(spaces);
+  }, on_error);
+}
+
+function load_importables(user, on_success, on_error) {
+  load_resource("get", "/users/"+user._id+"/importables", null, on_success, on_error);
+}
+
+function import_zip(user, filename, on_success, on_error) {
+  load_resource("get", "/users/"+user._id+"/import?zip="+filename, null, on_success, on_error);
+}
+
+function load_writable_folders(on_success, on_error) {
+  load_resource("get", "/spaces?writablefolders=true", null, on_success, on_error);
+}
+
+function load_history(s, on_success, on_error) {
+  load_resource("get", "/spaces/"+ s._id +"/digest", null, on_success, on_error);
+}
+
+function load_filtered_spaces(filter, on_success, on_error) {
+  load_resource("get", "/spaces?filter="+filter, null, on_success, on_error);
+}
+
+function load_spaces_search(query, on_success, on_error) {
+  load_resource("get", "/spaces?search="+query, null, on_success, on_error);
+}
+
+function load_artifacts(id, on_success, on_error) {
+  load_resource("get", "/spaces/"+id+"/artifacts", null, on_success, on_error);
+}
+
+function save_artifact(a, on_success, on_error) {
+  if (a._id) {
+    load_resource("put", "/spaces/"+a.space_id+"/artifacts/"+a._id,a,on_success,on_error);
+  } else {
+    load_resource("post", "/spaces/"+a.space_id+"/artifacts",a,on_success,on_error);
+  }
+}
+
+function save_pdf_file(space, point, file, zones, on_success, on_error, on_progress) {
+  load_resource("post", "/spaces/"+space._id+"/artifacts-pdf?filename="+file.name + "&x="+point.x+"&y="+point.y + "&zones="+zones,file,on_success,on_error,on_progress);
+}
+
+function save_artifact_file(a, file,filename, on_success, on_error, on_progress) {
+  load_resource("post", "/spaces/"+a.space_id+"/artifacts/"+a._id+"/payload?filename="+filename,file,on_success,on_error,on_progress);
+}
+
+function save_space(s, on_success, on_error) {
+  if (s._id) {
+    delete s['artifacts'];
+    load_resource("put", "/spaces/"+s._id,s,on_success,on_error);
+  } else {
+    load_resource("post", "/spaces",s,on_success,on_error);
+  }
+}
+
+function delete_space(s, on_success, on_error) {
+  load_resource("delete", "/spaces/"+s._id, null, on_success, on_error);
+}
+
+
+function delete_artifact(a, on_success, on_error) {
+  load_resource("delete", "/spaces/"+a.space_id+"/artifacts/"+a._id);
+}
+
+
+function duplicate_space(s, to_space_id, on_success, on_error) {
+  var path = "/spaces/"+s._id+"/duplicate";
+  if(to_space_id) {
+    path += "?parent_space_id=" + to_space_id
+  }
+  load_resource("post", path, null,on_success,on_error);
+}
+
+function load_members(space, on_success, on_error) {
+  load_resource("get", "/spaces/"+ space._id +"/memberships", null, on_success, on_error);
+}
+
+function create_membership(space, m, on_success, on_error) {
+  load_resource("post", "/spaces/"+ space._id +"/memberships", m, on_success, on_error);
+}
+
+function save_membership(space, m, on_success, on_error) {
+  load_resource("put", "/spaces/"+ space._id +"/memberships/" + m._id, m, on_success, on_error);
+}
+
+function delete_membership(space, m, on_success, on_error) {
+  load_resource("delete", "/spaces/"+ space._id +"/memberships/"+m._id, m, on_success, on_error);
+}
+
+function accept_invitation(id, code, on_success, on_error) {
+  load_resource("get", "/memberships/"+ id +"/accept?code="+code, null, on_success, on_error);
+}
+
+function get_join_link(space_id, on_success, on_error) {
+  load_resource("get", "/invitation_codes?space_id="+space_id, null, on_success, on_error);
+}
+
+function create_join_link(space_id, role, on_success, on_error) {
+  load_resource("post", "/invitation_codes", {join_role:role, sticky:true, space_id:space_id}, on_success, on_error);
+}
+
+function delete_join_link(link_id, on_success, on_error) {
+  load_resource("delete", "/invitation_codes/"+link_id, null, on_success, on_error);
+}
+
+function load_team_members(id, on_success, on_error) {
+  load_resource("get", "/teams/"+ id +"/memberships", null, function(team) {
+    on_success(team);
+  }, on_error);
+}
+
+function save_avatar_file(type, o, file, on_success, on_error) {
+  load_resource("post", "/"+type+"s/"+o._id+"/avatar", file, on_success,on_error);
+}
+
+function remove_avatar_file(type, o, on_success, on_error) {
+  load_resource("delete", "/"+type+"s/"+o._id+"/avatar", null, on_success,on_error);
+}
+
+function save_space_background_file(space, file, on_success, on_error) {
+  load_resource("post", "/spaces/"+space._id+"/background?filename="+file.name, file, on_success,on_error);
+}
+
+function save_user_background_file(user, file, on_success, on_error) {
+  load_resource("post", "/users/"+user._id+"/background", file, on_success,on_error);
+}
+
+function save_user_password(u, pass, newPass, on_success, on_error) {
+  load_resource("post", "/users/" + u._id + "/password", {old_password:pass, new_password:newPass}, on_success, on_error);
+}
+
+function get_featured_users(on_success, on_error) {
+  load_resource("get", "/users/featured", null, on_success, on_error);
+}
+
+function save_user(u, on_success, on_error) {
+  load_resource("put", "/users/"+u._id,u,on_success,on_error);
+}
+
+function delete_user(u, password, on_success, on_error) {
+  load_resource("delete", "/users/"+u._id +"?password="+password,null,on_success,on_error);
+}
+
+function create_user(name, email, password, password_confirmation, on_success, on_error) {
+  load_resource("post", "/users", {email:email, nickname:name, password:password, password_confirmation: password_confirmation}, on_success, on_error);
+}
+
+function create_session(email, password, on_success, on_error) {
+  load_resource("post", "/sessions", {email:email, password:password}, on_success, on_error);
+}
+
+function delete_session(on_success, on_error) {
+  load_resource("delete", "/sessions/current", null, on_success, on_error);
+}
+
+function create_oauthtoken(on_success, on_error) {
+  load_resource("get", "/users/oauth2callback/url", null, on_success, on_error);
+}
+
+function create_session_for_oauthtoken(token, on_success, on_error) {
+  load_resource("get", "/users/loginorsignupviagoogle?code="+token, null, on_success, on_error);
+}
+
+function create_password_reset(email, on_success, on_error) {
+  load_resource("post", "/users/password_reset_requests?email=" + encodeURIComponent(email), null,  on_success, on_error);
+}
+
+function confirm_password_reset(password, confirm, on_success, on_error) {
+  load_resource("post", "/users/password_reset_requests/"+confirm+"/confirm", {password:password}, on_success, on_error);
+}
+
+function confirm_user(user, token, on_success, on_error) {
+  load_resource("put", "/users/"+user._id+"/confirm", {token:token}, on_success, on_error);
+}
+
+function resent_confirm_mail(user, on_success, on_error) {
+  load_resource("post", "/users/"+user._id+"/confirm", {}, on_success, on_error);
+}
+
+function create_feedback(user, m, on_success, on_error) {
+  load_resource("post", "/users/feedback", {text: m}, on_success, on_error);
+}
+
+function save_team(u, on_success, on_error) {
+  load_resource("put", "/teams/"+u._id,u,on_success,on_error);
+}
+
+function load_comments(space_id, on_success, on_error) {
+  load_resource("get", "/spaces/"+space_id+"/messages", null, on_success, on_error);
+}
+
+function save_comment(space_id, data, on_success, on_error) {
+  load_resource("post", "/spaces/"+space_id +"/messages", data, on_success, on_error);
+}
+
+function delete_comment(space_id, message_id,on_success, on_error) {
+  load_resource("delete", "/spaces/"+space_id +"/messages/"+ message_id, null , on_success, on_error);
+}
+
+function update_comment(space_id, data, on_success, on_error) {
+  load_resource("post", "/spaces/"+space_id+"/messages/" + data._id , data, on_success, on_error);
+}
+
+function load_notifications(u, on_success, on_error) {
+  load_resource("get", "/notifications", null, on_success, on_error);
+}

+ 745 - 0
public/javascripts/clipboard.js

@@ -0,0 +1,745 @@
+/*!
+ * clipboard.js v1.5.5
+ * https://zenorocha.github.io/clipboard.js
+ *
+ * Licensed MIT © Zeno Rocha
+ */
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Clipboard = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+var matches = require('matches-selector')
+
+module.exports = function (element, selector, checkYoSelf) {
+  var parent = checkYoSelf ? element : element.parentNode
+
+  while (parent && parent !== document) {
+    if (matches(parent, selector)) return parent;
+    parent = parent.parentNode
+  }
+}
+
+},{"matches-selector":2}],2:[function(require,module,exports){
+
+/**
+ * Element prototype.
+ */
+
+var proto = Element.prototype;
+
+/**
+ * Vendor function.
+ */
+
+var vendor = proto.matchesSelector
+  || proto.webkitMatchesSelector
+  || proto.mozMatchesSelector
+  || proto.msMatchesSelector
+  || proto.oMatchesSelector;
+
+/**
+ * Expose `match()`.
+ */
+
+module.exports = match;
+
+/**
+ * Match `el` to `selector`.
+ *
+ * @param {Element} el
+ * @param {String} selector
+ * @return {Boolean}
+ * @api public
+ */
+
+function match(el, selector) {
+  if (vendor) return vendor.call(el, selector);
+  var nodes = el.parentNode.querySelectorAll(selector);
+  for (var i = 0; i < nodes.length; ++i) {
+    if (nodes[i] == el) return true;
+  }
+  return false;
+}
+},{}],3:[function(require,module,exports){
+var closest = require('closest');
+
+/**
+ * Delegates event to a selector.
+ *
+ * @param {Element} element
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function delegate(element, selector, type, callback) {
+    var listenerFn = listener.apply(this, arguments);
+
+    element.addEventListener(type, listenerFn);
+
+    return {
+        destroy: function() {
+            element.removeEventListener(type, listenerFn);
+        }
+    }
+}
+
+/**
+ * Finds closest match and invokes callback.
+ *
+ * @param {Element} element
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Function}
+ */
+function listener(element, selector, type, callback) {
+    return function(e) {
+        e.delegateTarget = closest(e.target, selector, true);
+
+        if (e.delegateTarget) {
+            callback.call(element, e);
+        }
+    }
+}
+
+module.exports = delegate;
+
+},{"closest":1}],4:[function(require,module,exports){
+/**
+ * Check if argument is a HTML element.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.node = function(value) {
+    return value !== undefined
+        && value instanceof HTMLElement
+        && value.nodeType === 1;
+};
+
+/**
+ * Check if argument is a list of HTML elements.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.nodeList = function(value) {
+    var type = Object.prototype.toString.call(value);
+
+    return value !== undefined
+        && (type === '[object NodeList]' || type === '[object HTMLCollection]')
+        && ('length' in value)
+        && (value.length === 0 || exports.node(value[0]));
+};
+
+/**
+ * Check if argument is a string.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.string = function(value) {
+    return typeof value === 'string'
+        || value instanceof String;
+};
+
+/**
+ * Check if argument is a function.
+ *
+ * @param {Object} value
+ * @return {Boolean}
+ */
+exports.function = function(value) {
+    var type = Object.prototype.toString.call(value);
+
+    return type === '[object Function]';
+};
+
+},{}],5:[function(require,module,exports){
+var is = require('./is');
+var delegate = require('delegate');
+
+/**
+ * Validates all params and calls the right
+ * listener function based on its target type.
+ *
+ * @param {String|HTMLElement|HTMLCollection|NodeList} target
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listen(target, type, callback) {
+    if (!target && !type && !callback) {
+        throw new Error('Missing required arguments');
+    }
+
+    if (!is.string(type)) {
+        throw new TypeError('Second argument must be a String');
+    }
+
+    if (!is.function(callback)) {
+        throw new TypeError('Third argument must be a Function');
+    }
+
+    if (is.node(target)) {
+        return listenNode(target, type, callback);
+    }
+    else if (is.nodeList(target)) {
+        return listenNodeList(target, type, callback);
+    }
+    else if (is.string(target)) {
+        return listenSelector(target, type, callback);
+    }
+    else {
+        throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');
+    }
+}
+
+/**
+ * Adds an event listener to a HTML element
+ * and returns a remove listener function.
+ *
+ * @param {HTMLElement} node
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listenNode(node, type, callback) {
+    node.addEventListener(type, callback);
+
+    return {
+        destroy: function() {
+            node.removeEventListener(type, callback);
+        }
+    }
+}
+
+/**
+ * Add an event listener to a list of HTML elements
+ * and returns a remove listener function.
+ *
+ * @param {NodeList|HTMLCollection} nodeList
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listenNodeList(nodeList, type, callback) {
+    Array.prototype.forEach.call(nodeList, function(node) {
+        node.addEventListener(type, callback);
+    });
+
+    return {
+        destroy: function() {
+            Array.prototype.forEach.call(nodeList, function(node) {
+                node.removeEventListener(type, callback);
+            });
+        }
+    }
+}
+
+/**
+ * Add an event listener to a selector
+ * and returns a remove listener function.
+ *
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} callback
+ * @return {Object}
+ */
+function listenSelector(selector, type, callback) {
+    return delegate(document.body, selector, type, callback);
+}
+
+module.exports = listen;
+
+},{"./is":4,"delegate":3}],6:[function(require,module,exports){
+function select(element) {
+    var selectedText;
+
+    if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
+        element.focus();
+        element.setSelectionRange(0, element.value.length);
+
+        selectedText = element.value;
+    }
+    else {
+        if (element.hasAttribute('contenteditable')) {
+            element.focus();
+        }
+
+        var selection = window.getSelection();
+        var range = document.createRange();
+
+        range.selectNodeContents(element);
+        selection.removeAllRanges();
+        selection.addRange(range);
+
+        selectedText = selection.toString();
+    }
+
+    return selectedText;
+}
+
+module.exports = select;
+
+},{}],7:[function(require,module,exports){
+function E () {
+	// Keep this empty so it's easier to inherit from
+  // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)
+}
+
+E.prototype = {
+	on: function (name, callback, ctx) {
+    var e = this.e || (this.e = {});
+
+    (e[name] || (e[name] = [])).push({
+      fn: callback,
+      ctx: ctx
+    });
+
+    return this;
+  },
+
+  once: function (name, callback, ctx) {
+    var self = this;
+    function listener () {
+      self.off(name, listener);
+      callback.apply(ctx, arguments);
+    };
+
+    listener._ = callback
+    return this.on(name, listener, ctx);
+  },
+
+  emit: function (name) {
+    var data = [].slice.call(arguments, 1);
+    var evtArr = ((this.e || (this.e = {}))[name] || []).slice();
+    var i = 0;
+    var len = evtArr.length;
+
+    for (i; i < len; i++) {
+      evtArr[i].fn.apply(evtArr[i].ctx, data);
+    }
+
+    return this;
+  },
+
+  off: function (name, callback) {
+    var e = this.e || (this.e = {});
+    var evts = e[name];
+    var liveEvents = [];
+
+    if (evts && callback) {
+      for (var i = 0, len = evts.length; i < len; i++) {
+        if (evts[i].fn !== callback && evts[i].fn._ !== callback)
+          liveEvents.push(evts[i]);
+      }
+    }
+
+    // Remove event from queue to prevent memory leak
+    // Suggested by https://github.com/lazd
+    // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910
+
+    (liveEvents.length)
+      ? e[name] = liveEvents
+      : delete e[name];
+
+    return this;
+  }
+};
+
+module.exports = E;
+
+},{}],8:[function(require,module,exports){
+'use strict';
+
+exports.__esModule = true;
+
+var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
+
+var _select = require('select');
+
+var _select2 = _interopRequireDefault(_select);
+
+/**
+ * Inner class which performs selection from either `text` or `target`
+ * properties and then executes copy or cut operations.
+ */
+
+var ClipboardAction = (function () {
+    /**
+     * @param {Object} options
+     */
+
+    function ClipboardAction(options) {
+        _classCallCheck(this, ClipboardAction);
+
+        this.resolveOptions(options);
+        this.initSelection();
+    }
+
+    /**
+     * Defines base properties passed from constructor.
+     * @param {Object} options
+     */
+
+    ClipboardAction.prototype.resolveOptions = function resolveOptions() {
+        var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
+
+        this.action = options.action;
+        this.emitter = options.emitter;
+        this.target = options.target;
+        this.text = options.text;
+        this.trigger = options.trigger;
+
+        this.selectedText = '';
+    };
+
+    /**
+     * Decides which selection strategy is going to be applied based
+     * on the existence of `text` and `target` properties.
+     */
+
+    ClipboardAction.prototype.initSelection = function initSelection() {
+        if (this.text && this.target) {
+            throw new Error('Multiple attributes declared, use either "target" or "text"');
+        } else if (this.text) {
+            this.selectFake();
+        } else if (this.target) {
+            this.selectTarget();
+        } else {
+            throw new Error('Missing required attributes, use either "target" or "text"');
+        }
+    };
+
+    /**
+     * Creates a fake textarea element, sets its value from `text` property,
+     * and makes a selection on it.
+     */
+
+    ClipboardAction.prototype.selectFake = function selectFake() {
+        var _this = this;
+
+        this.removeFake();
+
+        this.fakeHandler = document.body.addEventListener('click', function () {
+            return _this.removeFake();
+        });
+
+        this.fakeElem = document.createElement('textarea');
+        this.fakeElem.style.position = 'absolute';
+        this.fakeElem.style.left = '-9999px';
+        this.fakeElem.style.top = (window.pageYOffset || document.documentElement.scrollTop) + 'px';
+        this.fakeElem.setAttribute('readonly', '');
+        this.fakeElem.value = this.text;
+
+        document.body.appendChild(this.fakeElem);
+
+        this.selectedText = _select2['default'](this.fakeElem);
+        this.copyText();
+    };
+
+    /**
+     * Only removes the fake element after another click event, that way
+     * a user can hit `Ctrl+C` to copy because selection still exists.
+     */
+
+    ClipboardAction.prototype.removeFake = function removeFake() {
+        if (this.fakeHandler) {
+            document.body.removeEventListener('click');
+            this.fakeHandler = null;
+        }
+
+        if (this.fakeElem) {
+            document.body.removeChild(this.fakeElem);
+            this.fakeElem = null;
+        }
+    };
+
+    /**
+     * Selects the content from element passed on `target` property.
+     */
+
+    ClipboardAction.prototype.selectTarget = function selectTarget() {
+        this.selectedText = _select2['default'](this.target);
+        this.copyText();
+    };
+
+    /**
+     * Executes the copy operation based on the current selection.
+     */
+
+    ClipboardAction.prototype.copyText = function copyText() {
+        var succeeded = undefined;
+
+        try {
+            succeeded = document.execCommand(this.action);
+        } catch (err) {
+            succeeded = false;
+        }
+
+        this.handleResult(succeeded);
+    };
+
+    /**
+     * Fires an event based on the copy operation result.
+     * @param {Boolean} succeeded
+     */
+
+    ClipboardAction.prototype.handleResult = function handleResult(succeeded) {
+        if (succeeded) {
+            this.emitter.emit('success', {
+                action: this.action,
+                text: this.selectedText,
+                trigger: this.trigger,
+                clearSelection: this.clearSelection.bind(this)
+            });
+        } else {
+            this.emitter.emit('error', {
+                action: this.action,
+                trigger: this.trigger,
+                clearSelection: this.clearSelection.bind(this)
+            });
+        }
+    };
+
+    /**
+     * Removes current selection and focus from `target` element.
+     */
+
+    ClipboardAction.prototype.clearSelection = function clearSelection() {
+        if (this.target) {
+            this.target.blur();
+        }
+
+        window.getSelection().removeAllRanges();
+    };
+
+    /**
+     * Sets the `action` to be performed which can be either 'copy' or 'cut'.
+     * @param {String} action
+     */
+
+    /**
+     * Destroy lifecycle.
+     */
+
+    ClipboardAction.prototype.destroy = function destroy() {
+        this.removeFake();
+    };
+
+    _createClass(ClipboardAction, [{
+        key: 'action',
+        set: function set() {
+            var action = arguments.length <= 0 || arguments[0] === undefined ? 'copy' : arguments[0];
+
+            this._action = action;
+
+            if (this._action !== 'copy' && this._action !== 'cut') {
+                throw new Error('Invalid "action" value, use either "copy" or "cut"');
+            }
+        },
+
+        /**
+         * Gets the `action` property.
+         * @return {String}
+         */
+        get: function get() {
+            return this._action;
+        }
+
+        /**
+         * Sets the `target` property using an element
+         * that will be have its content copied.
+         * @param {Element} target
+         */
+    }, {
+        key: 'target',
+        set: function set(target) {
+            if (target !== undefined) {
+                if (target && typeof target === 'object' && target.nodeType === 1) {
+                    this._target = target;
+                } else {
+                    throw new Error('Invalid "target" value, use a valid Element');
+                }
+            }
+        },
+
+        /**
+         * Gets the `target` property.
+         * @return {String|HTMLElement}
+         */
+        get: function get() {
+            return this._target;
+        }
+    }]);
+
+    return ClipboardAction;
+})();
+
+exports['default'] = ClipboardAction;
+module.exports = exports['default'];
+
+},{"select":6}],9:[function(require,module,exports){
+'use strict';
+
+exports.__esModule = true;
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
+
+function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
+
+var _clipboardAction = require('./clipboard-action');
+
+var _clipboardAction2 = _interopRequireDefault(_clipboardAction);
+
+var _tinyEmitter = require('tiny-emitter');
+
+var _tinyEmitter2 = _interopRequireDefault(_tinyEmitter);
+
+var _goodListener = require('good-listener');
+
+var _goodListener2 = _interopRequireDefault(_goodListener);
+
+/**
+ * Base class which takes one or more elements, adds event listeners to them,
+ * and instantiates a new `ClipboardAction` on each click.
+ */
+
+var Clipboard = (function (_Emitter) {
+    _inherits(Clipboard, _Emitter);
+
+    /**
+     * @param {String|HTMLElement|HTMLCollection|NodeList} trigger
+     * @param {Object} options
+     */
+
+    function Clipboard(trigger, options) {
+        _classCallCheck(this, Clipboard);
+
+        _Emitter.call(this);
+
+        this.resolveOptions(options);
+        this.listenClick(trigger);
+    }
+
+    /**
+     * Helper function to retrieve attribute value.
+     * @param {String} suffix
+     * @param {Element} element
+     */
+
+    /**
+     * Defines if attributes would be resolved using internal setter functions
+     * or custom functions that were passed in the constructor.
+     * @param {Object} options
+     */
+
+    Clipboard.prototype.resolveOptions = function resolveOptions() {
+        var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0];
+
+        this.action = typeof options.action === 'function' ? options.action : this.defaultAction;
+        this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;
+        this.text = typeof options.text === 'function' ? options.text : this.defaultText;
+    };
+
+    /**
+     * Adds a click event listener to the passed trigger.
+     * @param {String|HTMLElement|HTMLCollection|NodeList} trigger
+     */
+
+    Clipboard.prototype.listenClick = function listenClick(trigger) {
+        var _this = this;
+
+        this.listener = _goodListener2['default'](trigger, 'click', function (e) {
+            return _this.onClick(e);
+        });
+    };
+
+    /**
+     * Defines a new `ClipboardAction` on each click event.
+     * @param {Event} e
+     */
+
+    Clipboard.prototype.onClick = function onClick(e) {
+        var trigger = e.delegateTarget || e.currentTarget;
+
+        if (this.clipboardAction) {
+            this.clipboardAction = null;
+        }
+
+        this.clipboardAction = new _clipboardAction2['default']({
+            action: this.action(trigger),
+            target: this.target(trigger),
+            text: this.text(trigger),
+            trigger: trigger,
+            emitter: this
+        });
+    };
+
+    /**
+     * Default `action` lookup function.
+     * @param {Element} trigger
+     */
+
+    Clipboard.prototype.defaultAction = function defaultAction(trigger) {
+        return getAttributeValue('action', trigger);
+    };
+
+    /**
+     * Default `target` lookup function.
+     * @param {Element} trigger
+     */
+
+    Clipboard.prototype.defaultTarget = function defaultTarget(trigger) {
+        var selector = getAttributeValue('target', trigger);
+
+        if (selector) {
+            return document.querySelector(selector);
+        }
+    };
+
+    /**
+     * Default `text` lookup function.
+     * @param {Element} trigger
+     */
+
+    Clipboard.prototype.defaultText = function defaultText(trigger) {
+        return getAttributeValue('text', trigger);
+    };
+
+    /**
+     * Destroy lifecycle.
+     */
+
+    Clipboard.prototype.destroy = function destroy() {
+        this.listener.destroy();
+
+        if (this.clipboardAction) {
+            this.clipboardAction.destroy();
+            this.clipboardAction = null;
+        }
+    };
+
+    return Clipboard;
+})(_tinyEmitter2['default']);
+
+function getAttributeValue(suffix, element) {
+    var attribute = 'data-clipboard-' + suffix;
+
+    if (!element.hasAttribute(attribute)) {
+        return;
+    }
+
+    return element.getAttribute(attribute);
+}
+
+exports['default'] = Clipboard;
+module.exports = exports['default'];
+
+},{"./clipboard-action":8,"good-listener":5,"tiny-emitter":7}]},{},[9])(9)
+});

+ 841 - 0
public/javascripts/fastclick.js

@@ -0,0 +1,841 @@
+;(function () {
+	'use strict';
+
+	/**
+	 * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs.
+	 *
+	 * @codingstandard ftlabs-jsv2
+	 * @copyright The Financial Times Limited [All Rights Reserved]
+	 * @license MIT License (see LICENSE.txt)
+	 */
+
+	/*jslint browser:true, node:true*/
+	/*global define, Event, Node*/
+
+
+	/**
+	 * Instantiate fast-clicking listeners on the specified layer.
+	 *
+	 * @constructor
+	 * @param {Element} layer The layer to listen on
+	 * @param {Object} [options={}] The options to override the defaults
+	 */
+	function FastClick(layer, options) {
+		var oldOnClick;
+
+		options = options || {};
+
+		/**
+		 * Whether a click is currently being tracked.
+		 *
+		 * @type boolean
+		 */
+		this.trackingClick = false;
+
+
+		/**
+		 * Timestamp for when click tracking started.
+		 *
+		 * @type number
+		 */
+		this.trackingClickStart = 0;
+
+
+		/**
+		 * The element being tracked for a click.
+		 *
+		 * @type EventTarget
+		 */
+		this.targetElement = null;
+
+
+		/**
+		 * X-coordinate of touch start event.
+		 *
+		 * @type number
+		 */
+		this.touchStartX = 0;
+
+
+		/**
+		 * Y-coordinate of touch start event.
+		 *
+		 * @type number
+		 */
+		this.touchStartY = 0;
+
+
+		/**
+		 * ID of the last touch, retrieved from Touch.identifier.
+		 *
+		 * @type number
+		 */
+		this.lastTouchIdentifier = 0;
+
+
+		/**
+		 * Touchmove boundary, beyond which a click will be cancelled.
+		 *
+		 * @type number
+		 */
+		this.touchBoundary = options.touchBoundary || 10;
+
+
+		/**
+		 * The FastClick layer.
+		 *
+		 * @type Element
+		 */
+		this.layer = layer;
+
+		/**
+		 * The minimum time between tap(touchstart and touchend) events
+		 *
+		 * @type number
+		 */
+		this.tapDelay = options.tapDelay || 200;
+
+		/**
+		 * The maximum time for a tap
+		 *
+		 * @type number
+		 */
+		this.tapTimeout = options.tapTimeout || 700;
+
+		if (FastClick.notNeeded(layer)) {
+			return;
+		}
+
+		// Some old versions of Android don't have Function.prototype.bind
+		function bind(method, context) {
+			return function() { return method.apply(context, arguments); };
+		}
+
+
+		var methods = ['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel'];
+		var context = this;
+		for (var i = 0, l = methods.length; i < l; i++) {
+			context[methods[i]] = bind(context[methods[i]], context);
+		}
+
+		// Set up event handlers as required
+		if (deviceIsAndroid) {
+			layer.addEventListener('mouseover', this.onMouse, true);
+			layer.addEventListener('mousedown', this.onMouse, true);
+			layer.addEventListener('mouseup', this.onMouse, true);
+		}
+
+		layer.addEventListener('click', this.onClick, true);
+		layer.addEventListener('touchstart', this.onTouchStart, false);
+		layer.addEventListener('touchmove', this.onTouchMove, false);
+		layer.addEventListener('touchend', this.onTouchEnd, false);
+		layer.addEventListener('touchcancel', this.onTouchCancel, false);
+
+		// Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
+		// which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick
+		// layer when they are cancelled.
+		if (!Event.prototype.stopImmediatePropagation) {
+			layer.removeEventListener = function(type, callback, capture) {
+				var rmv = Node.prototype.removeEventListener;
+				if (type === 'click') {
+					rmv.call(layer, type, callback.hijacked || callback, capture);
+				} else {
+					rmv.call(layer, type, callback, capture);
+				}
+			};
+
+			layer.addEventListener = function(type, callback, capture) {
+				var adv = Node.prototype.addEventListener;
+				if (type === 'click') {
+					adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
+						if (!event.propagationStopped) {
+							callback(event);
+						}
+					}), capture);
+				} else {
+					adv.call(layer, type, callback, capture);
+				}
+			};
+		}
+
+		// If a handler is already declared in the element's onclick attribute, it will be fired before
+		// FastClick's onClick handler. Fix this by pulling out the user-defined handler function and
+		// adding it as listener.
+		if (typeof layer.onclick === 'function') {
+
+			// Android browser on at least 3.2 requires a new reference to the function in layer.onclick
+			// - the old one won't work if passed to addEventListener directly.
+			oldOnClick = layer.onclick;
+			layer.addEventListener('click', function(event) {
+				oldOnClick(event);
+			}, false);
+			layer.onclick = null;
+		}
+	}
+
+	/**
+	* Windows Phone 8.1 fakes user agent string to look like Android and iPhone.
+	*
+	* @type boolean
+	*/
+	var deviceIsWindowsPhone = navigator.userAgent.indexOf("Windows Phone") >= 0;
+
+	/**
+	 * Android requires exceptions.
+	 *
+	 * @type boolean
+	 */
+	var deviceIsAndroid = navigator.userAgent.indexOf('Android') > 0 && !deviceIsWindowsPhone;
+
+
+	/**
+	 * iOS requires exceptions.
+	 *
+	 * @type boolean
+	 */
+	var deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent) && !deviceIsWindowsPhone;
+
+
+	/**
+	 * iOS 4 requires an exception for select elements.
+	 *
+	 * @type boolean
+	 */
+	var deviceIsIOS4 = deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent);
+
+
+	/**
+	 * iOS 6.0-7.* requires the target element to be manually derived
+	 *
+	 * @type boolean
+	 */
+	var deviceIsIOSWithBadTarget = deviceIsIOS && (/OS [6-7]_\d/).test(navigator.userAgent);
+
+	/**
+	 * BlackBerry requires exceptions.
+	 *
+	 * @type boolean
+	 */
+	var deviceIsBlackBerry10 = navigator.userAgent.indexOf('BB10') > 0;
+
+	/**
+	 * Determine whether a given element requires a native click.
+	 *
+	 * @param {EventTarget|Element} target Target DOM element
+	 * @returns {boolean} Returns true if the element needs a native click
+	 */
+	FastClick.prototype.needsClick = function(target) {
+		switch (target.nodeName.toLowerCase()) {
+
+		// Don't send a synthetic click to disabled inputs (issue #62)
+		case 'button':
+		case 'select':
+		case 'textarea':
+			if (target.disabled) {
+				return true;
+			}
+
+			break;
+		case 'input':
+
+			// File inputs need real clicks on iOS 6 due to a browser bug (issue #68)
+			if ((deviceIsIOS && target.type === 'file') || target.disabled) {
+				return true;
+			}
+
+			break;
+		case 'label':
+		case 'iframe': // iOS8 homescreen apps can prevent events bubbling into frames
+		case 'video':
+			return true;
+		}
+
+		return (/\bneedsclick\b/).test(target.className);
+	};
+
+
+	/**
+	 * Determine whether a given element requires a call to focus to simulate click into element.
+	 *
+	 * @param {EventTarget|Element} target Target DOM element
+	 * @returns {boolean} Returns true if the element requires a call to focus to simulate native click.
+	 */
+	FastClick.prototype.needsFocus = function(target) {
+		switch (target.nodeName.toLowerCase()) {
+		case 'textarea':
+			return true;
+		case 'select':
+			return !deviceIsAndroid;
+		case 'input':
+			switch (target.type) {
+			case 'button':
+			case 'checkbox':
+			case 'file':
+			case 'image':
+			case 'radio':
+			case 'submit':
+				return false;
+			}
+
+			// No point in attempting to focus disabled inputs
+			return !target.disabled && !target.readOnly;
+		default:
+			return (/\bneedsfocus\b/).test(target.className);
+		}
+	};
+
+
+	/**
+	 * Send a click event to the specified element.
+	 *
+	 * @param {EventTarget|Element} targetElement
+	 * @param {Event} event
+	 */
+	FastClick.prototype.sendClick = function(targetElement, event) {
+		var clickEvent, touch;
+
+		// On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24)
+		if (document.activeElement && document.activeElement !== targetElement) {
+			document.activeElement.blur();
+		}
+
+		touch = event.changedTouches[0];
+
+		// Synthesise a click event, with an extra attribute so it can be tracked
+		clickEvent = document.createEvent('MouseEvents');
+		clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
+		clickEvent.forwardedTouchEvent = true;
+		targetElement.dispatchEvent(clickEvent);
+	};
+
+	FastClick.prototype.determineEventType = function(targetElement) {
+
+		//Issue #159: Android Chrome Select Box does not open with a synthetic click event
+		if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
+			return 'mousedown';
+		}
+
+		return 'click';
+	};
+
+
+	/**
+	 * @param {EventTarget|Element} targetElement
+	 */
+	FastClick.prototype.focus = function(targetElement) {
+		var length;
+
+		// Issue #160: on iOS 7, some input elements (e.g. date datetime month) throw a vague TypeError on setSelectionRange. These elements don't have an integer value for the selectionStart and selectionEnd properties, but unfortunately that can't be used for detection because accessing the properties also throws a TypeError. Just check the type instead. Filed as Apple bug #15122724.
+		if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
+			length = targetElement.value.length;
+			targetElement.setSelectionRange(length, length);
+		} else {
+			targetElement.focus();
+		}
+	};
+
+
+	/**
+	 * Check whether the given target element is a child of a scrollable layer and if so, set a flag on it.
+	 *
+	 * @param {EventTarget|Element} targetElement
+	 */
+	FastClick.prototype.updateScrollParent = function(targetElement) {
+		var scrollParent, parentElement;
+
+		scrollParent = targetElement.fastClickScrollParent;
+
+		// Attempt to discover whether the target element is contained within a scrollable layer. Re-check if the
+		// target element was moved to another parent.
+		if (!scrollParent || !scrollParent.contains(targetElement)) {
+			parentElement = targetElement;
+			do {
+				if (parentElement.scrollHeight > parentElement.offsetHeight) {
+					scrollParent = parentElement;
+					targetElement.fastClickScrollParent = parentElement;
+					break;
+				}
+
+				parentElement = parentElement.parentElement;
+			} while (parentElement);
+		}
+
+		// Always update the scroll top tracker if possible.
+		if (scrollParent) {
+			scrollParent.fastClickLastScrollTop = scrollParent.scrollTop;
+		}
+	};
+
+
+	/**
+	 * @param {EventTarget} targetElement
+	 * @returns {Element|EventTarget}
+	 */
+	FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) {
+
+		// On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node.
+		if (eventTarget.nodeType === Node.TEXT_NODE) {
+			return eventTarget.parentNode;
+		}
+
+		return eventTarget;
+	};
+
+
+	/**
+	 * On touch start, record the position and scroll offset.
+	 *
+	 * @param {Event} event
+	 * @returns {boolean}
+	 */
+	FastClick.prototype.onTouchStart = function(event) {
+		var targetElement, touch, selection;
+
+		// Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111).
+		if (event.targetTouches.length > 1) {
+			return true;
+		}
+
+		targetElement = this.getTargetElementFromEventTarget(event.target);
+		touch = event.targetTouches[0];
+
+		if (deviceIsIOS) {
+
+			// Only trusted events will deselect text on iOS (issue #49)
+			selection = window.getSelection();
+			if (selection.rangeCount && !selection.isCollapsed) {
+				return true;
+			}
+
+			if (!deviceIsIOS4) {
+
+				// Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23):
+				// when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched
+				// with the same identifier as the touch event that previously triggered the click that triggered the alert.
+				// Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an
+				// immediately preceeding touch event (issue #52), so this fix is unavailable on that platform.
+				// Issue 120: touch.identifier is 0 when Chrome dev tools 'Emulate touch events' is set with an iOS device UA string,
+				// which causes all touch events to be ignored. As this block only applies to iOS, and iOS identifiers are always long,
+				// random integers, it's safe to to continue if the identifier is 0 here.
+				if (touch.identifier && touch.identifier === this.lastTouchIdentifier) {
+					event.preventDefault();
+					return false;
+				}
+
+				this.lastTouchIdentifier = touch.identifier;
+
+				// If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and:
+				// 1) the user does a fling scroll on the scrollable layer
+				// 2) the user stops the fling scroll with another tap
+				// then the event.target of the last 'touchend' event will be the element that was under the user's finger
+				// when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check
+				// is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42).
+				this.updateScrollParent(targetElement);
+			}
+		}
+
+		this.trackingClick = true;
+		this.trackingClickStart = event.timeStamp;
+		this.targetElement = targetElement;
+
+		this.touchStartX = touch.pageX;
+		this.touchStartY = touch.pageY;
+
+		// Prevent phantom clicks on fast double-tap (issue #36)
+		if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
+			event.preventDefault();
+		}
+
+		return true;
+	};
+
+
+	/**
+	 * Based on a touchmove event object, check whether the touch has moved past a boundary since it started.
+	 *
+	 * @param {Event} event
+	 * @returns {boolean}
+	 */
+	FastClick.prototype.touchHasMoved = function(event) {
+		var touch = event.changedTouches[0], boundary = this.touchBoundary;
+
+		if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) {
+			return true;
+		}
+
+		return false;
+	};
+
+
+	/**
+	 * Update the last position.
+	 *
+	 * @param {Event} event
+	 * @returns {boolean}
+	 */
+	FastClick.prototype.onTouchMove = function(event) {
+		if (!this.trackingClick) {
+			return true;
+		}
+
+		// If the touch has moved, cancel the click tracking
+		if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
+			this.trackingClick = false;
+			this.targetElement = null;
+		}
+
+		return true;
+	};
+
+
+	/**
+	 * Attempt to find the labelled control for the given label element.
+	 *
+	 * @param {EventTarget|HTMLLabelElement} labelElement
+	 * @returns {Element|null}
+	 */
+	FastClick.prototype.findControl = function(labelElement) {
+
+		// Fast path for newer browsers supporting the HTML5 control attribute
+		if (labelElement.control !== undefined) {
+			return labelElement.control;
+		}
+
+		// All browsers under test that support touch events also support the HTML5 htmlFor attribute
+		if (labelElement.htmlFor) {
+			return document.getElementById(labelElement.htmlFor);
+		}
+
+		// If no for attribute exists, attempt to retrieve the first labellable descendant element
+		// the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label
+		return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea');
+	};
+
+
+	/**
+	 * On touch end, determine whether to send a click event at once.
+	 *
+	 * @param {Event} event
+	 * @returns {boolean}
+	 */
+	FastClick.prototype.onTouchEnd = function(event) {
+		var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
+
+		if (!this.trackingClick) {
+			return true;
+		}
+
+		// Prevent phantom clicks on fast double-tap (issue #36)
+		if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
+			this.cancelNextClick = true;
+			return true;
+		}
+
+		if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
+			return true;
+		}
+
+		// Reset to prevent wrong click cancel on input (issue #156).
+		this.cancelNextClick = false;
+
+		this.lastClickTime = event.timeStamp;
+
+		trackingClickStart = this.trackingClickStart;
+		this.trackingClick = false;
+		this.trackingClickStart = 0;
+
+		// On some iOS devices, the targetElement supplied with the event is invalid if the layer
+		// is performing a transition or scroll, and has to be re-detected manually. Note that
+		// for this to function correctly, it must be called *after* the event target is checked!
+		// See issue #57; also filed as rdar://13048589 .
+		if (deviceIsIOSWithBadTarget) {
+			touch = event.changedTouches[0];
+
+			// In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null
+			targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
+			targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
+		}
+
+		targetTagName = targetElement.tagName.toLowerCase();
+		if (targetTagName === 'label') {
+			forElement = this.findControl(targetElement);
+			if (forElement) {
+				this.focus(targetElement);
+				if (deviceIsAndroid) {
+					return false;
+				}
+
+				targetElement = forElement;
+			}
+		} else if (this.needsFocus(targetElement)) {
+
+			// Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through.
+			// Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37).
+			if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
+				this.targetElement = null;
+				return false;
+			}
+
+			this.focus(targetElement);
+			this.sendClick(targetElement, event);
+
+			// Select elements need the event to go through on iOS 4, otherwise the selector menu won't open.
+			// Also this breaks opening selects when VoiceOver is active on iOS6, iOS7 (and possibly others)
+			if (!deviceIsIOS || targetTagName !== 'select') {
+				this.targetElement = null;
+				event.preventDefault();
+			}
+
+			return false;
+		}
+
+		if (deviceIsIOS && !deviceIsIOS4) {
+
+			// Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled
+			// and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42).
+			scrollParent = targetElement.fastClickScrollParent;
+			if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) {
+				return true;
+			}
+		}
+
+		// Prevent the actual click from going though - unless the target node is marked as requiring
+		// real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted.
+		if (!this.needsClick(targetElement)) {
+			event.preventDefault();
+			this.sendClick(targetElement, event);
+		}
+
+		return false;
+	};
+
+
+	/**
+	 * On touch cancel, stop tracking the click.
+	 *
+	 * @returns {void}
+	 */
+	FastClick.prototype.onTouchCancel = function() {
+		this.trackingClick = false;
+		this.targetElement = null;
+	};
+
+
+	/**
+	 * Determine mouse events which should be permitted.
+	 *
+	 * @param {Event} event
+	 * @returns {boolean}
+	 */
+	FastClick.prototype.onMouse = function(event) {
+
+		// If a target element was never set (because a touch event was never fired) allow the event
+		if (!this.targetElement) {
+			return true;
+		}
+
+		if (event.forwardedTouchEvent) {
+			return true;
+		}
+
+		// Programmatically generated events targeting a specific element should be permitted
+		if (!event.cancelable) {
+			return true;
+		}
+
+		// Derive and check the target element to see whether the mouse event needs to be permitted;
+		// unless explicitly enabled, prevent non-touch click events from triggering actions,
+		// to prevent ghost/doubleclicks.
+		if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
+
+			// Prevent any user-added listeners declared on FastClick element from being fired.
+			if (event.stopImmediatePropagation) {
+				event.stopImmediatePropagation();
+			} else {
+
+				// Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
+				event.propagationStopped = true;
+			}
+
+			// Cancel the event
+			event.stopPropagation();
+			event.preventDefault();
+
+			return false;
+		}
+
+		// If the mouse event is permitted, return true for the action to go through.
+		return true;
+	};
+
+
+	/**
+	 * On actual clicks, determine whether this is a touch-generated click, a click action occurring
+	 * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or
+	 * an actual click which should be permitted.
+	 *
+	 * @param {Event} event
+	 * @returns {boolean}
+	 */
+	FastClick.prototype.onClick = function(event) {
+		var permitted;
+
+		// It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early.
+		if (this.trackingClick) {
+			this.targetElement = null;
+			this.trackingClick = false;
+			return true;
+		}
+
+		// Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target.
+		if (event.target.type === 'submit' && event.detail === 0) {
+			return true;
+		}
+
+		permitted = this.onMouse(event);
+
+		// Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the browser's click doesn't go through.
+		if (!permitted) {
+			this.targetElement = null;
+		}
+
+		// If clicks are permitted, return true for the action to go through.
+		return permitted;
+	};
+
+
+	/**
+	 * Remove all FastClick's event listeners.
+	 *
+	 * @returns {void}
+	 */
+	FastClick.prototype.destroy = function() {
+		var layer = this.layer;
+
+		if (deviceIsAndroid) {
+			layer.removeEventListener('mouseover', this.onMouse, true);
+			layer.removeEventListener('mousedown', this.onMouse, true);
+			layer.removeEventListener('mouseup', this.onMouse, true);
+		}
+
+		layer.removeEventListener('click', this.onClick, true);
+		layer.removeEventListener('touchstart', this.onTouchStart, false);
+		layer.removeEventListener('touchmove', this.onTouchMove, false);
+		layer.removeEventListener('touchend', this.onTouchEnd, false);
+		layer.removeEventListener('touchcancel', this.onTouchCancel, false);
+	};
+
+
+	/**
+	 * Check whether FastClick is needed.
+	 *
+	 * @param {Element} layer The layer to listen on
+	 */
+	FastClick.notNeeded = function(layer) {
+		var metaViewport;
+		var chromeVersion;
+		var blackberryVersion;
+		var firefoxVersion;
+
+		// Devices that don't support touch don't need FastClick
+		if (typeof window.ontouchstart === 'undefined') {
+			return true;
+		}
+
+		// Chrome version - zero for other browsers
+		chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
+
+		if (chromeVersion) {
+
+			if (deviceIsAndroid) {
+				metaViewport = document.querySelector('meta[name=viewport]');
+
+				if (metaViewport) {
+					// Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89)
+					if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
+						return true;
+					}
+					// Chrome 32 and above with width=device-width or less don't need FastClick
+					if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) {
+						return true;
+					}
+				}
+
+			// Chrome desktop doesn't need FastClick (issue #15)
+			} else {
+				return true;
+			}
+		}
+
+		if (deviceIsBlackBerry10) {
+			blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/);
+
+			// BlackBerry 10.3+ does not require Fastclick library.
+			// https://github.com/ftlabs/fastclick/issues/251
+			if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) {
+				metaViewport = document.querySelector('meta[name=viewport]');
+
+				if (metaViewport) {
+					// user-scalable=no eliminates click delay.
+					if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
+						return true;
+					}
+					// width=device-width (or less than device-width) eliminates click delay.
+					if (document.documentElement.scrollWidth <= window.outerWidth) {
+						return true;
+					}
+				}
+			}
+		}
+
+		// IE10 with -ms-touch-action: none or manipulation, which disables double-tap-to-zoom (issue #97)
+		if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') {
+			return true;
+		}
+
+		// Firefox version - zero for other browsers
+		firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
+
+		if (firefoxVersion >= 27) {
+			// Firefox 27+ does not have tap delay if the content is not zoomable - https://bugzilla.mozilla.org/show_bug.cgi?id=922896
+
+			metaViewport = document.querySelector('meta[name=viewport]');
+			if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) {
+				return true;
+			}
+		}
+
+		// IE11: prefixed -ms-touch-action is no longer supported and it's recomended to use non-prefixed version
+		// http://msdn.microsoft.com/en-us/library/windows/apps/Hh767313.aspx
+		if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') {
+			return true;
+		}
+
+		return false;
+	};
+
+
+	/**
+	 * Factory method for creating a FastClick object
+	 *
+	 * @param {Element} layer The layer to listen on
+	 * @param {Object} [options={}] The options to override the defaults
+	 */
+	FastClick.attach = function(layer, options) {
+		return new FastClick(layer, options);
+	};
+
+
+	if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {
+
+		// AMD. Register as an anonymous module.
+		define(function() {
+			return FastClick;
+		});
+	} else if (typeof module !== 'undefined' && module.exports) {
+		module.exports = FastClick.attach;
+		module.exports.FastClick = FastClick;
+	} else {
+		window.FastClick = FastClick;
+	}
+}());

+ 249 - 0
public/javascripts/helper.js

@@ -0,0 +1,249 @@
+function validateEmail(email) { 
+  var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+  return re.test(email);
+}
+
+function zero_pad(num) {
+  zero = 2 - num.toString().length + 1;
+  return Array(+(zero > 0 && zero)).join("0") + num;
+}
+
+function format_time(seconds) {
+  if (isNaN(seconds)) seconds = 0;
+  return zero_pad(parseInt(seconds/60)) + ":" + zero_pad(parseInt(seconds%60));
+}
+
+var url_to_links_rx = /(^|[\s\n]|>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
+function urls_to_links(text) {
+  return text.replace(url_to_links_rx, "$1<a target='_blank' href='$2'>$2</a>");
+}
+
+// http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
+function get_query_param(name) {
+  name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
+  var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
+      results = regex.exec(location.search);
+  return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
+}
+
+// http://stackoverflow.com/questions/1349404/generate-a-string-of-5-random-characters-in-javascript
+function random_string(len) {
+  var text = "";
+  var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!-_";
+
+  for (var i=0; i < len; i++) {
+    text += possible.charAt(Math.floor(Math.random() * possible.length));
+  }
+
+  return text;
+}
+
+function fixup_touches(evt) {
+  // convert touch events
+  var e = evt;
+  if (evt.originalEvent) e = evt.originalEvent;
+  evt = {
+    pageX: evt.pageX,
+    pageY: evt.pageY,
+    offsetX: evt.offsetX,
+    offsetY: evt.offsetY,
+    clientX: evt.clientX,
+    clientY: evt.clientY,
+    layerX: evt.layerX,
+    layerY: evt.layerY,
+    target: evt.target,
+    currentTarget: evt.currentTarget
+  };
+  
+  if (e.changedTouches && e.changedTouches.length) {
+    evt.pageX = e.changedTouches[0].pageX;
+    evt.pageY = e.changedTouches[0].pageY;
+  } else if (e.touches && e.touches.length) {
+    evt.pageX = e.touches[0].pageX;
+    evt.pageY = e.touches[0].pageY;
+  }
+  return evt;
+}
+
+function rgb_to_hex(r, g, b) {
+  return ((1 << 24) + (parseInt(r) << 16) + (parseInt(g) << 8) + parseInt(b)).toString(16).slice(1);
+}
+
+function hex_to_rgba(color) {
+
+  if (!color || color == "transparent") {
+    return {r:0,g:0,b:0,a:0};
+  }
+
+  if (color.match("rgb\\(")) {
+    color = color.replace("rgb(","").replace(")","").split(",");
+    return {
+      r: color[0],
+      g: color[1],
+      b: color[2],
+      a: 255
+    };
+  }
+
+  if (color.match("rgba\\(")) {
+    color = color.replace("rgba(","").replace(")","").split(",");
+    return {
+      r: color[0],
+      g: color[1],
+      b: color[2],
+      a: color[3]*255
+    };
+  }
+
+  var r = parseInt(color.substr(1,2), 16);
+  var g = parseInt(color.substr(3,2), 16);
+  var b = parseInt(color.substr(5,2), 16);
+  var a = 255;
+  if (color.length>7) {
+    a = parseInt(color.substr(7,2), 16);
+  }
+  return {r:r,g:g,b:b,a:a};
+}
+
+function rgb_to_hsv () {
+  var rr, gg, bb,
+      r = arguments[0] / 255,
+      g = arguments[1] / 255,
+      b = arguments[2] / 255,
+      h, s,
+      v = Math.max(r, g, b),
+      diff = v - Math.min(r, g, b),
+      diffc = function(c) {
+        return (v - c) / 6 / diff + 1 / 2;
+      };
+
+  if (diff == 0) {
+    h = s = 0;
+  } else {
+    s = diff / v;
+    rr = diffc(r);
+    gg = diffc(g);
+    bb = diffc(b);
+
+    if (r === v) {
+      h = bb - gg;
+    } else if (g === v) {
+      h = (1 / 3) + rr - bb;
+    } else if (b === v) {
+      h = (2 / 3) + gg - rr;
+    }
+
+    if (h < 0) {
+      h += 1;
+    } else if (h > 1) {
+      h -= 1;
+    }
+  }
+  return {
+    h: h || 0,
+    s: s || 0,
+    v: v || 0
+  };
+}
+
+// values?
+function hsv_to_rgb(h, s, v) {
+  var r, g, b, i, f, p, q, t;
+  if (h && s === undefined && v === undefined) {
+      s = h.s, v = h.v, h = h.h;
+  }
+  i = Math.floor(h * 6);
+  f = h * 6 - i;
+  p = v * (1 - s);
+  q = v * (1 - f * s);
+  t = v * (1 - (1 - f) * s);
+  switch (i % 6) {
+      case 0: r = v, g = t, b = p; break;
+      case 1: r = q, g = v, b = p; break;
+      case 2: r = p, g = v, b = t; break;
+      case 3: r = p, g = q, b = v; break;
+      case 4: r = t, g = p, b = v; break;
+      case 5: r = v, g = p, b = q; break;
+  }
+  return {
+      r: Math.round(r * 255),
+      g: Math.round(g * 255),
+      b: Math.round(b * 255)
+  };
+}
+
+temp_grid_canvas = document.createElement("canvas");
+
+function render_grid(w,h,divisions) {
+  temp_grid_canvas.width = w;
+  temp_grid_canvas.height = h;
+
+  var step = w / divisions;
+
+  var ctx = temp_grid_canvas.getContext('2d');
+  ctx.strokeStyle = "#f0f0f0";
+  ctx.lineWidth = 1;
+
+  var gc1 = "rgba(60,60,60,0.125)";
+  var gc2 = "rgba(60,60,60,0.075)";
+
+  for (var y=0; y<h; y+=step) {
+    if (y==0) {
+      ctx.fillStyle = gc1;
+    } else {
+      ctx.fillStyle = gc2;
+    }
+    ctx.fillRect(0,y,w,1);
+  }
+  for (var x=0; x<h; x+=step) {
+    if (x==0) {
+      ctx.fillStyle = gc1;
+    } else {
+      ctx.fillStyle = gc2;
+    }
+    ctx.fillRect(x,0,1,h);
+  }
+
+  var data_url = temp_grid_canvas.toDataURL()
+  return data_url;
+}
+
+function focus_contenteditable(el, end) {
+  range = document.createRange();
+
+  if (!range || !el) return;
+  var p = $(el).find("p");
+  if (!p.length) return;
+
+  // get last paragraph
+  p = p[p.length-1];
+  
+  range.selectNodeContents(p);
+  
+  selection = window.getSelection();
+  selection.removeAllRanges();
+  if (range.toString()!="Text") {
+    // move cursor to the end
+    range.collapse(false);
+  }
+  selection.addRange(range);
+
+  el.focus();
+}
+
+function setup_exclusive_audio_video_playback() {
+  document.addEventListener('play', function(e) {
+
+    var tags = ["audio","video"];
+
+    for (var i=0; i<tags.length; i++) {
+      var tag = tags[i];
+      var players = document.getElementsByTagName(tag);
+      for (var i = 0, len = players.length; i < len; i++) {
+        if (players[i] != e.target) {
+          players[i].pause();
+        }
+      }
+    }
+  }, true);
+}

+ 2332 - 0
public/javascripts/i18next-1.11.2.js

@@ -0,0 +1,2332 @@
+// i18next, v1.11.2
+// Copyright (c)2015 Jan Mühlemann (jamuhl).
+// Distributed under MIT license
+// http://i18next.com
+(function(root) {
+
+    // add indexOf to non ECMA-262 standard compliant browsers
+    if (!Array.prototype.indexOf) {
+        Array.prototype.indexOf = function (searchElement /*, fromIndex */ ) {
+            "use strict";
+            if (this == null) {
+                throw new TypeError();
+            }
+            var t = Object(this);
+            var len = t.length >>> 0;
+            if (len === 0) {
+                return -1;
+            }
+            var n = 0;
+            if (arguments.length > 0) {
+                n = Number(arguments[1]);
+                if (n != n) { // shortcut for verifying if it's NaN
+                    n = 0;
+                } else if (n != 0 && n != Infinity && n != -Infinity) {
+                    n = (n > 0 || -1) * Math.floor(Math.abs(n));
+                }
+            }
+            if (n >= len) {
+                return -1;
+            }
+            var k = n >= 0 ? n : Math.max(len - Math.abs(n), 0);
+            for (; k < len; k++) {
+                if (k in t && t[k] === searchElement) {
+                    return k;
+                }
+            }
+            return -1;
+        }
+    }
+    
+    // add lastIndexOf to non ECMA-262 standard compliant browsers
+    if (!Array.prototype.lastIndexOf) {
+        Array.prototype.lastIndexOf = function(searchElement /*, fromIndex*/) {
+            "use strict";
+            if (this == null) {
+                throw new TypeError();
+            }
+            var t = Object(this);
+            var len = t.length >>> 0;
+            if (len === 0) {
+                return -1;
+            }
+            var n = len;
+            if (arguments.length > 1) {
+                n = Number(arguments[1]);
+                if (n != n) {
+                    n = 0;
+                } else if (n != 0 && n != (1 / 0) && n != -(1 / 0)) {
+                    n = (n > 0 || -1) * Math.floor(Math.abs(n));
+                }
+            }
+            var k = n >= 0 ? Math.min(n, len - 1) : len - Math.abs(n);
+            for (; k >= 0; k--) {
+                if (k in t && t[k] === searchElement) {
+                    return k;
+                }
+            }
+            return -1;
+        };
+    }
+    
+    // Add string trim for IE8.
+    if (typeof String.prototype.trim !== 'function') {
+        String.prototype.trim = function() {
+            return this.replace(/^\s+|\s+$/g, ''); 
+        }
+    }
+
+    var $ = root.jQuery || root.Zepto
+      , i18n = {}
+      , resStore = {}
+      , currentLng
+      , replacementCounter = 0
+      , languages = []
+      , initialized = false
+      , sync = {}
+      , conflictReference = null;
+
+
+
+    // Export the i18next object for **CommonJS**. 
+    // If we're not in CommonJS, add `i18n` to the
+    // global object or to jquery.
+    if (typeof module !== 'undefined' && module.exports) {
+        module.exports = i18n;
+    } else {
+        if ($) {
+            $.i18n = $.i18n || i18n;
+        }
+        
+        if (root.i18n) {
+        	conflictReference = root.i18n;
+        }
+        root.i18n = i18n;
+    }
+    sync = {
+    
+        load: function(lngs, options, cb) {
+            if (options.useLocalStorage) {
+                sync._loadLocal(lngs, options, function(err, store) {
+                    var missingLngs = [];
+                    for (var i = 0, len = lngs.length; i < len; i++) {
+                        if (!store[lngs[i]]) missingLngs.push(lngs[i]);
+                    }
+    
+                    if (missingLngs.length > 0) {
+                        sync._fetch(missingLngs, options, function(err, fetched) {
+                            f.extend(store, fetched);
+                            sync._storeLocal(fetched);
+    
+                            cb(err, store);
+                        });
+                    } else {
+                        cb(err, store);
+                    }
+                });
+            } else {
+                sync._fetch(lngs, options, function(err, store){
+                    cb(err, store);
+                });
+            }
+        },
+    
+        _loadLocal: function(lngs, options, cb) {
+            var store = {}
+              , nowMS = new Date().getTime();
+    
+            if(window.localStorage) {
+    
+                var todo = lngs.length;
+    
+                f.each(lngs, function(key, lng) {
+                    var local = f.localStorage.getItem('res_' + lng);
+    
+                    if (local) {
+                        local = JSON.parse(local);
+    
+                        if (local.i18nStamp && local.i18nStamp + options.localStorageExpirationTime > nowMS) {
+                            store[lng] = local;
+                        }
+                    }
+    
+                    todo--; // wait for all done befor callback
+                    if (todo === 0) cb(null, store);
+                });
+            }
+        },
+    
+        _storeLocal: function(store) {
+            if(window.localStorage) {
+                for (var m in store) {
+                    store[m].i18nStamp = new Date().getTime();
+                    f.localStorage.setItem('res_' + m, JSON.stringify(store[m]));
+                }
+            }
+            return;
+        },
+    
+        _fetch: function(lngs, options, cb) {
+            var ns = options.ns
+              , store = {};
+            
+            if (!options.dynamicLoad) {
+                var todo = ns.namespaces.length * lngs.length
+                  , errors;
+    
+                // load each file individual
+                f.each(ns.namespaces, function(nsIndex, nsValue) {
+                    f.each(lngs, function(lngIndex, lngValue) {
+                        
+                        // Call this once our translation has returned.
+                        var loadComplete = function(err, data) {
+                            if (err) {
+                                errors = errors || [];
+                                errors.push(err);
+                            }
+                            store[lngValue] = store[lngValue] || {};
+                            store[lngValue][nsValue] = data;
+    
+                            todo--; // wait for all done befor callback
+                            if (todo === 0) cb(errors, store);
+                        };
+                        
+                        if(typeof options.customLoad == 'function'){
+                            // Use the specified custom callback.
+                            options.customLoad(lngValue, nsValue, options, loadComplete);
+                        } else {
+                            //~ // Use our inbuilt sync.
+                            sync._fetchOne(lngValue, nsValue, options, loadComplete);
+                        }
+                    });
+                });
+            } else {
+                // Call this once our translation has returned.
+                var loadComplete = function(err, data) {
+                    cb(err, data);
+                };
+    
+                if(typeof options.customLoad == 'function'){
+                    // Use the specified custom callback.
+                    options.customLoad(lngs, ns.namespaces, options, loadComplete);
+                } else {
+                    var url = applyReplacement(options.resGetPath, { lng: lngs.join('+'), ns: ns.namespaces.join('+') });
+                    // load all needed stuff once
+                    f.ajax({
+                        url: url,
+                        cache: options.cache,
+                        success: function(data, status, xhr) {
+                            f.log('loaded: ' + url);
+                            loadComplete(null, data);
+                        },
+                        error : function(xhr, status, error) {
+                            f.log('failed loading: ' + url);
+                            loadComplete('failed loading resource.json error: ' + error);
+                        },
+                        dataType: "json",
+                        async : options.getAsync,
+                        timeout: options.ajaxTimeout
+                    });
+                }    
+            }
+        },
+    
+        _fetchOne: function(lng, ns, options, done) {
+            var url = applyReplacement(options.resGetPath, { lng: lng, ns: ns });
+            f.ajax({
+                url: url,
+                cache: options.cache,
+                success: function(data, status, xhr) {
+                    f.log('loaded: ' + url);
+                    done(null, data);
+                },
+                error : function(xhr, status, error) {
+                    if ((status && status == 200) || (xhr && xhr.status && xhr.status == 200)) {
+                        // file loaded but invalid json, stop waste time !
+                        f.error('There is a typo in: ' + url);
+                    } else if ((status && status == 404) || (xhr && xhr.status && xhr.status == 404)) {
+                        f.log('Does not exist: ' + url);
+                    } else {
+                        var theStatus = status ? status : ((xhr && xhr.status) ? xhr.status : null);
+                        f.log(theStatus + ' when loading ' + url);
+                    }
+                    
+                    done(error, {});
+                },
+                dataType: "json",
+                async : options.getAsync,
+                timeout: options.ajaxTimeout,
+                headers: options.headers
+            });
+        },
+    
+        postMissing: function(lng, ns, key, defaultValue, lngs) {
+            var payload = {};
+            payload[key] = defaultValue;
+    
+            var urls = [];
+    
+            if (o.sendMissingTo === 'fallback' && o.fallbackLng[0] !== false) {
+                for (var i = 0; i < o.fallbackLng.length; i++) {
+                    urls.push({lng: o.fallbackLng[i], url: applyReplacement(o.resPostPath, { lng: o.fallbackLng[i], ns: ns })});
+                }
+            } else if (o.sendMissingTo === 'current' || (o.sendMissingTo === 'fallback' && o.fallbackLng[0] === false) ) {
+                urls.push({lng: lng, url: applyReplacement(o.resPostPath, { lng: lng, ns: ns })});
+            } else if (o.sendMissingTo === 'all') {
+                for (var i = 0, l = lngs.length; i < l; i++) {
+                    urls.push({lng: lngs[i], url: applyReplacement(o.resPostPath, { lng: lngs[i], ns: ns })});
+                }
+            }
+    
+            for (var y = 0, len = urls.length; y < len; y++) {
+                var item = urls[y];
+                f.ajax({
+                    url: item.url,
+                    type: o.sendType,
+                    data: payload,
+                    success: function(data, status, xhr) {
+                        f.log('posted missing key \'' + key + '\' to: ' + item.url);
+    
+                        // add key to resStore
+                        var keys = key.split('.');
+                        var x = 0;
+                        var value = resStore[item.lng][ns];
+                        while (keys[x]) {
+                            if (x === keys.length - 1) {
+                                value = value[keys[x]] = defaultValue;
+                            } else {
+                                value = value[keys[x]] = value[keys[x]] || {};
+                            }
+                            x++;
+                        }
+                    },
+                    error : function(xhr, status, error) {
+                        f.log('failed posting missing key \'' + key + '\' to: ' + item.url);
+                    },
+                    dataType: "json",
+                    async : o.postAsync,
+                    timeout: o.ajaxTimeout
+                });
+            }
+        },
+    
+        reload: reload
+    };
+    // defaults
+    var o = {
+        lng: undefined,
+        load: 'all',
+        preload: [],
+        lowerCaseLng: false,
+        returnObjectTrees: false,
+        fallbackLng: ['dev'],
+        fallbackNS: [],
+        detectLngQS: 'setLng',
+        detectLngFromLocalStorage: false,
+        ns: {
+            namespaces: ['translation'],
+            defaultNs: 'translation'
+        },
+        fallbackOnNull: true,
+        fallbackOnEmpty: false,
+        fallbackToDefaultNS: false,
+        showKeyIfEmpty: false,
+        nsseparator: ':',
+        keyseparator: '.',
+        selectorAttr: 'data-i18n',
+        debug: false,
+    
+        resGetPath: 'locales/__lng__/__ns__.json',
+        resPostPath: 'locales/add/__lng__/__ns__',
+    
+        getAsync: true,
+        postAsync: true,
+    
+        resStore: undefined,
+        useLocalStorage: false,
+        localStorageExpirationTime: 7*24*60*60*1000,
+    
+        dynamicLoad: false,
+        sendMissing: false,
+        sendMissingTo: 'fallback', // current | all
+        sendType: 'POST',
+    
+        interpolationPrefix: '__',
+        interpolationSuffix: '__',
+        defaultVariables: false,
+        reusePrefix: '$t(',
+        reuseSuffix: ')',
+        pluralSuffix: '_plural',
+        pluralNotFound: ['plural_not_found', Math.random()].join(''),
+        contextNotFound: ['context_not_found', Math.random()].join(''),
+        escapeInterpolation: false,
+        indefiniteSuffix: '_indefinite',
+        indefiniteNotFound: ['indefinite_not_found', Math.random()].join(''),
+    
+        setJqueryExt: true,
+        defaultValueFromContent: true,
+        useDataAttrOptions: false,
+        cookieExpirationTime: undefined,
+        useCookie: true,
+        cookieName: 'i18next',
+        cookieDomain: undefined,
+    
+        objectTreeKeyHandler: undefined,
+        postProcess: undefined,
+        parseMissingKey: undefined,
+        missingKeyHandler: sync.postMissing,
+        ajaxTimeout: 0,
+    
+        shortcutFunction: 'sprintf' // or: defaultValue
+    };
+    function _extend(target, source) {
+        if (!source || typeof source === 'function') {
+            return target;
+        }
+    
+        for (var attr in source) { target[attr] = source[attr]; }
+        return target;
+    }
+    
+    function _deepExtend(target, source, overwrite) {
+        for (var prop in source)
+            if (prop in target) {
+                // If we reached a leaf string in target or source then replace with source or skip depending on the 'overwrite' switch
+                if (typeof target[prop] === 'string' || target[prop] instanceof String || typeof source[prop] === 'string' || source[prop] instanceof String) {
+                    if (overwrite) {
+                        target[prop] = source[prop];
+                    }
+                } else {
+                    _deepExtend(target[prop], source[prop], overwrite);
+                }
+            } else {
+                target[prop] = source[prop];
+            }
+        return target;
+    }
+    
+    function _each(object, callback, args) {
+        var name, i = 0,
+            length = object.length,
+            isObj = length === undefined || Object.prototype.toString.apply(object) !== '[object Array]' || typeof object === "function";
+    
+        if (args) {
+            if (isObj) {
+                for (name in object) {
+                    if (callback.apply(object[name], args) === false) {
+                        break;
+                    }
+                }
+            } else {
+                for ( ; i < length; ) {
+                    if (callback.apply(object[i++], args) === false) {
+                        break;
+                    }
+                }
+            }
+    
+        // A special, fast, case for the most common use of each
+        } else {
+            if (isObj) {
+                for (name in object) {
+                    if (callback.call(object[name], name, object[name]) === false) {
+                        break;
+                    }
+                }
+            } else {
+                for ( ; i < length; ) {
+                    if (callback.call(object[i], i, object[i++]) === false) {
+                        break;
+                    }
+                }
+            }
+        }
+    
+        return object;
+    }
+    
+    var _entityMap = {
+        "&": "&amp;",
+        "<": "&lt;",
+        ">": "&gt;",
+        '"': '&quot;',
+        "'": '&#39;',
+        "/": '&#x2F;'
+    };
+    
+    function _escape(data) {
+        if (typeof data === 'string') {
+            return data.replace(/[&<>"'\/]/g, function (s) {
+                return _entityMap[s];
+            });
+        }else{
+            return data;
+        }
+    }
+    
+    function _ajax(options) {
+    
+        // v0.5.0 of https://github.com/goloroden/http.js
+        var getXhr = function (callback) {
+            // Use the native XHR object if the browser supports it.
+            if (window.XMLHttpRequest) {
+                return callback(null, new XMLHttpRequest());
+            } else if (window.ActiveXObject) {
+                // In Internet Explorer check for ActiveX versions of the XHR object.
+                try {
+                    return callback(null, new ActiveXObject("Msxml2.XMLHTTP"));
+                } catch (e) {
+                    return callback(null, new ActiveXObject("Microsoft.XMLHTTP"));
+                }
+            }
+    
+            // If no XHR support was found, throw an error.
+            return callback(new Error());
+        };
+    
+        var encodeUsingUrlEncoding = function (data) {
+            if(typeof data === 'string') {
+                return data;
+            }
+    
+            var result = [];
+            for(var dataItem in data) {
+                if(data.hasOwnProperty(dataItem)) {
+                    result.push(encodeURIComponent(dataItem) + '=' + encodeURIComponent(data[dataItem]));
+                }
+            }
+    
+            return result.join('&');
+        };
+    
+        var utf8 = function (text) {
+            text = text.replace(/\r\n/g, '\n');
+            var result = '';
+    
+            for(var i = 0; i < text.length; i++) {
+                var c = text.charCodeAt(i);
+    
+                if(c < 128) {
+                        result += String.fromCharCode(c);
+                } else if((c > 127) && (c < 2048)) {
+                        result += String.fromCharCode((c >> 6) | 192);
+                        result += String.fromCharCode((c & 63) | 128);
+                } else {
+                        result += String.fromCharCode((c >> 12) | 224);
+                        result += String.fromCharCode(((c >> 6) & 63) | 128);
+                        result += String.fromCharCode((c & 63) | 128);
+                }
+            }
+    
+            return result;
+        };
+    
+        var base64 = function (text) {
+            var keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
+    
+            text = utf8(text);
+            var result = '',
+                    chr1, chr2, chr3,
+                    enc1, enc2, enc3, enc4,
+                    i = 0;
+    
+            do {
+                chr1 = text.charCodeAt(i++);
+                chr2 = text.charCodeAt(i++);
+                chr3 = text.charCodeAt(i++);
+    
+                enc1 = chr1 >> 2;
+                enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
+                enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
+                enc4 = chr3 & 63;
+    
+                if(isNaN(chr2)) {
+                    enc3 = enc4 = 64;
+                } else if(isNaN(chr3)) {
+                    enc4 = 64;
+                }
+    
+                result +=
+                    keyStr.charAt(enc1) +
+                    keyStr.charAt(enc2) +
+                    keyStr.charAt(enc3) +
+                    keyStr.charAt(enc4);
+                chr1 = chr2 = chr3 = '';
+                enc1 = enc2 = enc3 = enc4 = '';
+            } while(i < text.length);
+    
+            return result;
+        };
+    
+        var mergeHeaders = function () {
+            // Use the first header object as base.
+            var result = arguments[0];
+    
+            // Iterate through the remaining header objects and add them.
+            for(var i = 1; i < arguments.length; i++) {
+                var currentHeaders = arguments[i];
+                for(var header in currentHeaders) {
+                    if(currentHeaders.hasOwnProperty(header)) {
+                        result[header] = currentHeaders[header];
+                    }
+                }
+            }
+    
+            // Return the merged headers.
+            return result;
+        };
+    
+        var ajax = function (method, url, options, callback) {
+            // Adjust parameters.
+            if(typeof options === 'function') {
+                callback = options;
+                options = {};
+            }
+    
+            // Set default parameter values.
+            options.cache = options.cache || false;
+            options.data = options.data || {};
+            options.headers = options.headers || {};
+            options.jsonp = options.jsonp || false;
+            options.async = options.async === undefined ? true : options.async;
+    
+            // Merge the various header objects.
+            var headers = mergeHeaders({
+                'accept': '*/*',
+                'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
+            }, ajax.headers, options.headers);
+    
+            // Encode the data according to the content-type.
+            var payload;
+            if (headers['content-type'] === 'application/json') {
+                payload = JSON.stringify(options.data);
+            } else {
+                payload = encodeUsingUrlEncoding(options.data);
+            }
+    
+            // Specially prepare GET requests: Setup the query string, handle caching and make a JSONP call
+            // if neccessary.
+            if(method === 'GET') {
+                // Setup the query string.
+                var queryString = [];
+                if(payload) {
+                    queryString.push(payload);
+                    payload = null;
+                }
+    
+                // Handle caching.
+                if(!options.cache) {
+                    queryString.push('_=' + (new Date()).getTime());
+                }
+    
+                // If neccessary prepare the query string for a JSONP call.
+                if(options.jsonp) {
+                    queryString.push('callback=' + options.jsonp);
+                    queryString.push('jsonp=' + options.jsonp);
+                }
+    
+                // Merge the query string and attach it to the url.
+                queryString = queryString.join('&');
+                if (queryString.length > 1) {
+                    if (url.indexOf('?') > -1) {
+                        url += '&' + queryString;
+                    } else {
+                        url += '?' + queryString;
+                    }
+                }
+    
+                // Make a JSONP call if neccessary.
+                if(options.jsonp) {
+                    var head = document.getElementsByTagName('head')[0];
+                    var script = document.createElement('script');
+                    script.type = 'text/javascript';
+                    script.src = url;
+                    head.appendChild(script);
+                    return;
+                }
+            }
+    
+            // Since we got here, it is no JSONP request, so make a normal XHR request.
+            getXhr(function (err, xhr) {
+                if(err) return callback(err);
+    
+                // Open the request.
+                xhr.open(method, url, options.async);
+    
+                // Set the request headers.
+                for(var header in headers) {
+                    if(headers.hasOwnProperty(header)) {
+                        xhr.setRequestHeader(header, headers[header]);
+                    }
+                }
+    
+                // Handle the request events.
+                xhr.onreadystatechange = function () {
+                    if(xhr.readyState === 4) {
+                        var data = xhr.responseText || '';
+    
+                        // If no callback is given, return.
+                        if(!callback) {
+                            return;
+                        }
+    
+                        // Return an object that provides access to the data as text and JSON.
+                        callback(xhr.status, {
+                            text: function () {
+                                return data;
+                            },
+    
+                            json: function () {
+                                try {
+                                    return JSON.parse(data)
+                                } catch (e) {
+                                    f.error('Can not parse JSON. URL: ' + url);
+                                    return {};
+                                }
+                            }
+                        });
+                    }
+                };
+    
+                // Actually send the XHR request.
+                xhr.send(payload);
+            });
+        };
+    
+        // Define the external interface.
+        var http = {
+            authBasic: function (username, password) {
+                ajax.headers['Authorization'] = 'Basic ' + base64(username + ':' + password);
+            },
+    
+            connect: function (url, options, callback) {
+                return ajax('CONNECT', url, options, callback);
+            },
+    
+            del: function (url, options, callback) {
+                return ajax('DELETE', url, options, callback);
+            },
+    
+            get: function (url, options, callback) {
+                return ajax('GET', url, options, callback);
+            },
+    
+            head: function (url, options, callback) {
+                return ajax('HEAD', url, options, callback);
+            },
+    
+            headers: function (headers) {
+                ajax.headers = headers || {};
+            },
+    
+            isAllowed: function (url, verb, callback) {
+                this.options(url, function (status, data) {
+                    callback(data.text().indexOf(verb) !== -1);
+                });
+            },
+    
+            options: function (url, options, callback) {
+                return ajax('OPTIONS', url, options, callback);
+            },
+    
+            patch: function (url, options, callback) {
+                return ajax('PATCH', url, options, callback);
+            },
+    
+            post: function (url, options, callback) {
+                return ajax('POST', url, options, callback);
+            },
+    
+            put: function (url, options, callback) {
+                return ajax('PUT', url, options, callback);
+            },
+    
+            trace: function (url, options, callback) {
+                return ajax('TRACE', url, options, callback);
+            }
+        };
+    
+    
+        var methode = options.type ? options.type.toLowerCase() : 'get';
+    
+        http[methode](options.url, options, function (status, data) {
+            // file: protocol always gives status code 0, so check for data
+            if (status === 200 || (status === 0 && data.text())) {
+                options.success(data.json(), status, null);
+            } else {
+                options.error(data.text(), status, null);
+            }
+        });
+    }
+    
+    var _cookie = {
+        create: function(name,value,minutes,domain) {
+            var expires;
+            if (minutes) {
+                var date = new Date();
+                date.setTime(date.getTime()+(minutes*60*1000));
+                expires = "; expires="+date.toGMTString();
+            }
+            else expires = "";
+            domain = (domain)? "domain="+domain+";" : "";
+            document.cookie = name+"="+value+expires+";"+domain+"path=/";
+        },
+    
+        read: function(name) {
+            var nameEQ = name + "=";
+            var ca = document.cookie.split(';');
+            for(var i=0;i < ca.length;i++) {
+                var c = ca[i];
+                while (c.charAt(0)==' ') c = c.substring(1,c.length);
+                if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length,c.length);
+            }
+            return null;
+        },
+    
+        remove: function(name) {
+            this.create(name,"",-1);
+        }
+    };
+    
+    var cookie_noop = {
+        create: function(name,value,minutes,domain) {},
+        read: function(name) { return null; },
+        remove: function(name) {}
+    };
+    
+    
+    
+    // move dependent functions to a container so that
+    // they can be overriden easier in no jquery environment (node.js)
+    var f = {
+        extend: $ ? $.extend : _extend,
+        deepExtend: _deepExtend,
+        each: $ ? $.each : _each,
+        ajax: $ ? $.ajax : (typeof document !== 'undefined' ? _ajax : function() {}),
+        cookie: typeof document !== 'undefined' ? _cookie : cookie_noop,
+        detectLanguage: detectLanguage,
+        escape: _escape,
+        log: function(str) {
+            if (o.debug && typeof console !== "undefined") console.log(str);
+        },
+        error: function(str) {
+            if (typeof console !== "undefined") console.error(str);
+        },
+        getCountyIndexOfLng: function(lng) {
+            var lng_index = 0;
+            if (lng === 'nb-NO' || lng === 'nn-NO' || lng === 'nb-no' || lng === 'nn-no') lng_index = 1;
+            return lng_index;
+        },
+        toLanguages: function(lng, fallbackLng) {
+            var log = this.log;
+    
+            fallbackLng = fallbackLng || o.fallbackLng;
+            if (typeof fallbackLng === 'string')
+                fallbackLng = [fallbackLng];
+    
+            function applyCase(l) {
+                var ret = l;
+    
+                if (typeof l === 'string' && l.indexOf('-') > -1) {
+                    var parts = l.split('-');
+    
+                    ret = o.lowerCaseLng ?
+                        parts[0].toLowerCase() +  '-' + parts[1].toLowerCase() :
+                        parts[0].toLowerCase() +  '-' + parts[1].toUpperCase();
+                } else {
+                    ret = o.lowerCaseLng ? l.toLowerCase() : l;
+                }
+    
+                return ret;
+            }
+    
+            var languages = [];
+            var whitelist = o.lngWhitelist || false;
+            var addLanguage = function(language){
+              //reject langs not whitelisted
+              if(!whitelist || whitelist.indexOf(language) > -1){
+                languages.push(language);
+              }else{
+                log('rejecting non-whitelisted language: ' + language);
+              }
+            };
+            if (typeof lng === 'string' && lng.indexOf('-') > -1) {
+                var parts = lng.split('-');
+    
+                if (o.load !== 'unspecific') addLanguage(applyCase(lng));
+                if (o.load !== 'current') addLanguage(applyCase(parts[this.getCountyIndexOfLng(lng)]));
+            } else {
+                addLanguage(applyCase(lng));
+            }
+    
+            for (var i = 0; i < fallbackLng.length; i++) {
+                if (languages.indexOf(fallbackLng[i]) === -1 && fallbackLng[i]) languages.push(applyCase(fallbackLng[i]));
+            }
+            return languages;
+        },
+        regexEscape: function(str) {
+            return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
+        },
+        regexReplacementEscape: function(strOrFn) {
+            if (typeof strOrFn === 'string') {
+                return strOrFn.replace(/\$/g, "$$$$");
+            } else {
+                return strOrFn;
+            }
+        },
+        localStorage: {
+            setItem: function(key, value) {
+                if (window.localStorage) {
+                    try {
+                        window.localStorage.setItem(key, value);
+                    } catch (e) {
+                        f.log('failed to set value for key "' + key + '" to localStorage.');
+                    }
+                }
+            },
+            getItem: function(key, value) {
+                if (window.localStorage) {
+                    try {
+                        return window.localStorage.getItem(key, value);
+                    } catch (e) {
+                        f.log('failed to get value for key "' + key + '" from localStorage.');
+                        return undefined;
+                    }
+                }
+            }
+        }
+    };
+    function init(options, cb) {
+    
+        if (typeof options === 'function') {
+            cb = options;
+            options = {};
+        }
+        options = options || {};
+    
+        // override defaults with passed in options
+        f.extend(o, options);
+        delete o.fixLng; /* passed in each time */
+    
+        // override functions: .log(), .detectLanguage(), etc
+        if (o.functions) {
+            delete o.functions;
+            f.extend(f, options.functions);
+        }
+    
+        // create namespace object if namespace is passed in as string
+        if (typeof o.ns == 'string') {
+            o.ns = { namespaces: [o.ns], defaultNs: o.ns};
+        }
+    
+        // fallback namespaces
+        if (typeof o.fallbackNS == 'string') {
+            o.fallbackNS = [o.fallbackNS];
+        }
+    
+        // fallback languages
+        if (typeof o.fallbackLng == 'string' || typeof o.fallbackLng == 'boolean') {
+            o.fallbackLng = [o.fallbackLng];
+        }
+    
+        // escape prefix/suffix
+        o.interpolationPrefixEscaped = f.regexEscape(o.interpolationPrefix);
+        o.interpolationSuffixEscaped = f.regexEscape(o.interpolationSuffix);
+    
+        if (!o.lng) o.lng = f.detectLanguage();
+    
+        languages = f.toLanguages(o.lng);
+        currentLng = languages[0];
+        f.log('currentLng set to: ' + currentLng);
+    
+        if (o.useCookie && f.cookie.read(o.cookieName) !== currentLng){ //cookie is unset or invalid
+            f.cookie.create(o.cookieName, currentLng, o.cookieExpirationTime, o.cookieDomain);
+        }
+        if (o.detectLngFromLocalStorage && typeof document !== 'undefined' && window.localStorage) {
+            f.localStorage.setItem('i18next_lng', currentLng);
+        }
+    
+        var lngTranslate = translate;
+        if (options.fixLng) {
+            lngTranslate = function(key, options) {
+                options = options || {};
+                options.lng = options.lng || lngTranslate.lng;
+                return translate(key, options);
+            };
+            lngTranslate.lng = currentLng;
+        }
+    
+        pluralExtensions.setCurrentLng(currentLng);
+    
+        // add JQuery extensions
+        if ($ && o.setJqueryExt) {
+            addJqueryFunct && addJqueryFunct();
+        } else {
+           addJqueryLikeFunctionality && addJqueryLikeFunctionality();
+        }
+    
+        // jQuery deferred
+        var deferred;
+        if ($ && $.Deferred) {
+            deferred = $.Deferred();
+        }
+    
+        // return immidiatly if res are passed in
+        if (o.resStore) {
+            resStore = o.resStore;
+            initialized = true;
+            if (cb) cb(null, lngTranslate);
+            if (deferred) deferred.resolve(lngTranslate);
+            if (deferred) return deferred.promise();
+            return;
+        }
+    
+        // languages to load
+        var lngsToLoad = f.toLanguages(o.lng);
+        if (typeof o.preload === 'string') o.preload = [o.preload];
+        for (var i = 0, l = o.preload.length; i < l; i++) {
+            var pres = f.toLanguages(o.preload[i]);
+            for (var y = 0, len = pres.length; y < len; y++) {
+                if (lngsToLoad.indexOf(pres[y]) < 0) {
+                    lngsToLoad.push(pres[y]);
+                }
+            }
+        }
+    
+        // else load them
+        i18n.sync.load(lngsToLoad, o, function(err, store) {
+            resStore = store;
+            initialized = true;
+    
+            if (cb) cb(err, lngTranslate);
+            if (deferred) (!err ? deferred.resolve : deferred.reject)(err || lngTranslate);
+        });
+    
+        if (deferred) return deferred.promise();
+    }
+    
+    function isInitialized() {
+        return initialized;
+    }
+    function preload(lngs, cb) {
+        if (typeof lngs === 'string') lngs = [lngs];
+        for (var i = 0, l = lngs.length; i < l; i++) {
+            if (o.preload.indexOf(lngs[i]) < 0) {
+                o.preload.push(lngs[i]);
+            }
+        }
+        return init(cb);
+    }
+    
+    function addResourceBundle(lng, ns, resources, deep, overwrite) {
+        if (typeof ns !== 'string') {
+            resources = ns;
+            ns = o.ns.defaultNs;
+        } else if (o.ns.namespaces.indexOf(ns) < 0) {
+            o.ns.namespaces.push(ns);
+        }
+    
+        resStore[lng] = resStore[lng] || {};
+        resStore[lng][ns] = resStore[lng][ns] || {};
+    
+        if (deep) {
+            f.deepExtend(resStore[lng][ns], resources, overwrite);
+        } else {
+            f.extend(resStore[lng][ns], resources);
+        }
+        if (o.useLocalStorage) {
+            sync._storeLocal(resStore);
+        }
+    }
+    
+    function hasResourceBundle(lng, ns) {
+        if (typeof ns !== 'string') {
+            ns = o.ns.defaultNs;
+        }
+    
+        resStore[lng] = resStore[lng] || {};
+        var res = resStore[lng][ns] || {};
+    
+        var hasValues = false;
+        for(var prop in res) {
+            if (res.hasOwnProperty(prop)) {
+                hasValues = true;
+            }
+        }
+    
+        return hasValues;
+    }
+    
+    function getResourceBundle(lng, ns) {
+        if (typeof ns !== 'string') {
+            ns = o.ns.defaultNs;
+        }
+    
+        resStore[lng] = resStore[lng] || {};
+        return f.extend({}, resStore[lng][ns]);
+    }
+    
+    function removeResourceBundle(lng, ns) {
+        if (typeof ns !== 'string') {
+            ns = o.ns.defaultNs;
+        }
+    
+        resStore[lng] = resStore[lng] || {};
+        resStore[lng][ns] = {};
+        if (o.useLocalStorage) {
+            sync._storeLocal(resStore);
+        }
+    }
+    
+    function addResource(lng, ns, key, value) {
+        if (typeof ns !== 'string') {
+            resource = ns;
+            ns = o.ns.defaultNs;
+        } else if (o.ns.namespaces.indexOf(ns) < 0) {
+            o.ns.namespaces.push(ns);
+        }
+    
+        resStore[lng] = resStore[lng] || {};
+        resStore[lng][ns] = resStore[lng][ns] || {};
+    
+        var keys = key.split(o.keyseparator);
+        var x = 0;
+        var node = resStore[lng][ns];
+        var origRef = node;
+    
+        while (keys[x]) {
+            if (x == keys.length - 1)
+                node[keys[x]] = value;
+            else {
+                if (node[keys[x]] == null)
+                    node[keys[x]] = {};
+    
+                node = node[keys[x]];
+            }
+            x++;
+        }
+        if (o.useLocalStorage) {
+            sync._storeLocal(resStore);
+        }
+    }
+    
+    function addResources(lng, ns, resources) {
+        if (typeof ns !== 'string') {
+            resources = ns;
+            ns = o.ns.defaultNs;
+        } else if (o.ns.namespaces.indexOf(ns) < 0) {
+            o.ns.namespaces.push(ns);
+        }
+    
+        for (var m in resources) {
+            if (typeof resources[m] === 'string') addResource(lng, ns, m, resources[m]);
+        }
+    }
+    
+    function setDefaultNamespace(ns) {
+        o.ns.defaultNs = ns;
+    }
+    
+    function loadNamespace(namespace, cb) {
+        loadNamespaces([namespace], cb);
+    }
+    
+    function loadNamespaces(namespaces, cb) {
+        var opts = {
+            dynamicLoad: o.dynamicLoad,
+            resGetPath: o.resGetPath,
+            getAsync: o.getAsync,
+            customLoad: o.customLoad,
+            ns: { namespaces: namespaces, defaultNs: ''} /* new namespaces to load */
+        };
+    
+        // languages to load
+        var lngsToLoad = f.toLanguages(o.lng);
+        if (typeof o.preload === 'string') o.preload = [o.preload];
+        for (var i = 0, l = o.preload.length; i < l; i++) {
+            var pres = f.toLanguages(o.preload[i]);
+            for (var y = 0, len = pres.length; y < len; y++) {
+                if (lngsToLoad.indexOf(pres[y]) < 0) {
+                    lngsToLoad.push(pres[y]);
+                }
+            }
+        }
+    
+        // check if we have to load
+        var lngNeedLoad = [];
+        for (var a = 0, lenA = lngsToLoad.length; a < lenA; a++) {
+            var needLoad = false;
+            var resSet = resStore[lngsToLoad[a]];
+            if (resSet) {
+                for (var b = 0, lenB = namespaces.length; b < lenB; b++) {
+                    if (!resSet[namespaces[b]]) needLoad = true;
+                }
+            } else {
+                needLoad = true;
+            }
+    
+            if (needLoad) lngNeedLoad.push(lngsToLoad[a]);
+        }
+    
+        if (lngNeedLoad.length) {
+            i18n.sync._fetch(lngNeedLoad, opts, function(err, store) {
+                var todo = namespaces.length * lngNeedLoad.length;
+    
+                // load each file individual
+                f.each(namespaces, function(nsIndex, nsValue) {
+    
+                    // append namespace to namespace array
+                    if (o.ns.namespaces.indexOf(nsValue) < 0) {
+                        o.ns.namespaces.push(nsValue);
+                    }
+    
+                    f.each(lngNeedLoad, function(lngIndex, lngValue) {
+                        resStore[lngValue] = resStore[lngValue] || {};
+                        resStore[lngValue][nsValue] = store[lngValue][nsValue];
+    
+                        todo--; // wait for all done befor callback
+                        if (todo === 0 && cb) {
+                            if (o.useLocalStorage) i18n.sync._storeLocal(resStore);
+                            cb();
+                        }
+                    });
+                });
+            });
+        } else {
+            if (cb) cb();
+        }
+    }
+    
+    function setLng(lng, options, cb) {
+        if (typeof options === 'function') {
+            cb = options;
+            options = {};
+        } else if (!options) {
+            options = {};
+        }
+    
+        options.lng = lng;
+        return init(options, cb);
+    }
+    
+    function lng() {
+        return currentLng;
+    }
+    
+    function dir() {   
+        var rtlLangs = [ "ar", "shu", "sqr", "ssh", "xaa", "yhd", "yud", "aao", "abh", "abv", "acm",
+            "acq", "acw", "acx", "acy", "adf", "ads", "aeb", "aec", "afb", "ajp", "apc", "apd", "arb",
+            "arq", "ars", "ary", "arz", "auz", "avl", "ayh", "ayl", "ayn", "ayp", "bbz", "pga", "he",
+            "iw", "ps", "pbt", "pbu", "pst", "prp", "prd", "ur", "ydd", "yds", "yih", "ji", "yi", "hbo",
+            "men", "xmn", "fa", "jpr", "peo", "pes", "prs", "dv", "sam"
+        ];
+    
+        if ( rtlLangs.some( function( lang ) {
+                return new RegExp( '^' + lang ).test( currentLng );
+            } ) ) {
+            return 'rtl';
+        }
+        return 'ltr';
+    }
+    
+    function reload(cb) {
+        resStore = {};
+        setLng(currentLng, cb);
+    }
+    
+    function noConflict() {
+        
+        window.i18next = window.i18n;
+    
+        if (conflictReference) {
+            window.i18n = conflictReference;
+        } else {
+            delete window.i18n;
+        }
+    }
+    function addJqueryFunct() {
+        // $.t shortcut
+        $.t = $.t || translate;
+    
+        function parse(ele, key, options) {
+            if (key.length === 0) return;
+    
+            var attr = 'text';
+    
+            if (key.indexOf('[') === 0) {
+                var parts = key.split(']');
+                key = parts[1];
+                attr = parts[0].substr(1, parts[0].length-1);
+            }
+    
+            if (key.indexOf(';') === key.length-1) {
+                key = key.substr(0, key.length-2);
+            }
+    
+            var optionsToUse;
+            if (attr === 'html') {
+                optionsToUse = o.defaultValueFromContent ? $.extend({ defaultValue: ele.html() }, options) : options;
+                ele.html($.t(key, optionsToUse));
+            } else if (attr === 'text') {
+                optionsToUse = o.defaultValueFromContent ? $.extend({ defaultValue: ele.text() }, options) : options;
+                ele.text($.t(key, optionsToUse));
+            } else if (attr === 'prepend') {
+                optionsToUse = o.defaultValueFromContent ? $.extend({ defaultValue: ele.html() }, options) : options;
+                ele.prepend($.t(key, optionsToUse));
+            } else if (attr === 'append') {
+                optionsToUse = o.defaultValueFromContent ? $.extend({ defaultValue: ele.html() }, options) : options;
+                ele.append($.t(key, optionsToUse));
+            } else if (attr.indexOf("data-") === 0) {
+                var dataAttr = attr.substr(("data-").length);
+                optionsToUse = o.defaultValueFromContent ? $.extend({ defaultValue: ele.data(dataAttr) }, options) : options;
+                var translated = $.t(key, optionsToUse);
+                //we change into the data cache
+                ele.data(dataAttr, translated);
+                //we change into the dom
+                ele.attr(attr, translated);
+            } else {
+                optionsToUse = o.defaultValueFromContent ? $.extend({ defaultValue: ele.attr(attr) }, options) : options;
+                ele.attr(attr, $.t(key, optionsToUse));
+            }
+        }
+    
+        function localize(ele, options) {
+            var key = ele.attr(o.selectorAttr);
+            if (!key && typeof key !== 'undefined' && key !== false) key = ele.text() || ele.val();
+            if (!key) return;
+    
+            var target = ele
+              , targetSelector = ele.data("i18n-target");
+            if (targetSelector) {
+                target = ele.find(targetSelector) || ele;
+            }
+    
+            if (!options && o.useDataAttrOptions === true) {
+                options = ele.data("i18n-options");
+            }
+            options = options || {};
+    
+            if (key.indexOf(';') >= 0) {
+                var keys = key.split(';');
+    
+                $.each(keys, function(m, k) {
+                    if (k !== '') parse(target, k, options);
+                });
+    
+            } else {
+                parse(target, key, options);
+            }
+    
+            if (o.useDataAttrOptions === true) {
+              var clone = $.extend({lng: 'non', lngs: [], _origLng: 'non'}, options);
+              delete clone.lng;
+              delete clone.lngs;
+              delete clone._origLng;
+              ele.data("i18n-options", clone);
+            }
+        }
+    
+        // fn
+        $.fn.i18n = function (options) {
+            return this.each(function() {
+                // localize element itself
+                localize($(this), options);
+    
+                // localize childs
+                var elements =  $(this).find('[' + o.selectorAttr + ']');
+                elements.each(function() {
+                    localize($(this), options);
+                });
+            });
+        };
+    }
+    function addJqueryLikeFunctionality() {
+    
+        function parse(ele, key, options) {
+            if (key.length === 0) return;
+    
+            var attr = 'text';
+    
+            if (key.indexOf('[') === 0) {
+                var parts = key.split(']');
+                key = parts[1];
+                attr = parts[0].substr(1, parts[0].length-1);
+            }
+    
+            if (key.indexOf(';') === key.length-1) {
+                key = key.substr(0, key.length-2);
+            }
+    
+            if (attr === 'html') {
+                ele.innerHTML = translate(key, options);
+            } else if (attr === 'text') {
+                ele.textContent = translate(key, options);
+            } else if (attr === 'prepend') {
+                ele.insertAdjacentHTML(translate(key, options), 'afterbegin');
+            } else if (attr === 'append') {
+                ele.insertAdjacentHTML(translate(key, options), 'beforeend');
+            } else {
+                ele.setAttribute(attr, translate(key, options));
+            }
+        }
+    
+        function localize(ele, options) {
+            var key = ele.getAttribute(o.selectorAttr);
+            if (!key && typeof key !== 'undefined' && key !== false) key = ele.textContent || ele.value;
+            if (!key) return;
+    
+            var target = ele
+              , targetSelector = ele.getAttribute("i18n-target");
+            if (targetSelector) {
+                target = ele.querySelector(targetSelector) || ele;
+            }
+            
+            if (key.indexOf(';') >= 0) {
+                var keys = key.split(';'), index = 0, length = keys.length;
+                
+                for ( ; index < length; index++) {
+                    if (keys[index] !== '') parse(target, keys[index], options);
+                }
+    
+            } else {
+                parse(target, key, options);
+            }
+        }
+    
+        // fn
+        i18n.translateObject = function (object, options) {
+            // localize childs
+            var elements =  object.querySelectorAll('[' + o.selectorAttr + ']');
+            var index = 0, length = elements.length;
+            for ( ; index < length; index++) {
+                localize(elements[index], options);
+            }
+        };
+    }
+    function applyReplacement(str, replacementHash, nestedKey, options) {
+        if (!str) return str;
+    
+        options = options || replacementHash; // first call uses replacement hash combined with options
+        if (str.indexOf(options.interpolationPrefix || o.interpolationPrefix) < 0) return str;
+    
+        var prefix = options.interpolationPrefix ? f.regexEscape(options.interpolationPrefix) : o.interpolationPrefixEscaped
+          , suffix = options.interpolationSuffix ? f.regexEscape(options.interpolationSuffix) : o.interpolationSuffixEscaped
+          , keyseparator = options.keyseparator || o.keyseparator
+          , unEscapingSuffix = 'HTML'+suffix;
+    
+        var hash = replacementHash.replace && typeof replacementHash.replace === 'object' ? replacementHash.replace : replacementHash;
+        var replacementRegex = new RegExp([prefix, '(.+?)', '(HTML)?', suffix].join(''), 'g');
+        var escapeInterpolation = options.escapeInterpolation || o.escapeInterpolation;
+        return str.replace(replacementRegex, function (wholeMatch, keyMatch, htmlMatched) {
+            // Check for recursive matches of object
+            var objectMatching = hash;
+            var keyLeaf = keyMatch;
+            while (keyLeaf.indexOf(keyseparator) >= 0 && typeof objectMatching === 'object' && objectMatching) {
+                var propName = keyLeaf.slice(0, keyLeaf.indexOf(keyseparator));
+                keyLeaf = keyLeaf.slice(keyLeaf.indexOf(keyseparator) + 1);
+                objectMatching = objectMatching[propName];
+            }
+            if (objectMatching && typeof objectMatching === 'object' && objectMatching.hasOwnProperty(keyLeaf)) {
+                    var value = objectMatching[keyLeaf];
+                if (escapeInterpolation && !htmlMatched) {
+                    return f.escape(objectMatching[keyLeaf]);
+                } else {
+                    return objectMatching[keyLeaf];
+                }
+            } else {
+                return wholeMatch;
+            }
+        });
+    }
+    
+    // append it to functions
+    f.applyReplacement = applyReplacement;
+    
+    function applyReuse(translated, options) {
+        var comma = ',';
+        var options_open = '{';
+        var options_close = '}';
+    
+        var opts = f.extend({}, options);
+        delete opts.postProcess;
+        delete opts.isFallbackLookup;
+    
+        while (translated.indexOf(o.reusePrefix) != -1) {
+            replacementCounter++;
+            if (replacementCounter > o.maxRecursion) { break; } // safety net for too much recursion
+            var index_of_opening = translated.lastIndexOf(o.reusePrefix);
+            var index_of_end_of_closing = translated.indexOf(o.reuseSuffix, index_of_opening) + o.reuseSuffix.length;
+            var token = translated.substring(index_of_opening, index_of_end_of_closing);
+            var token_without_symbols = token.replace(o.reusePrefix, '').replace(o.reuseSuffix, '');
+    
+            if (index_of_end_of_closing <= index_of_opening) {
+                f.error('there is an missing closing in following translation value', translated);
+                return '';
+            }
+    
+            if (token_without_symbols.indexOf(comma) != -1) {
+                var index_of_token_end_of_closing = token_without_symbols.indexOf(comma);
+                if (token_without_symbols.indexOf(options_open, index_of_token_end_of_closing) != -1 && token_without_symbols.indexOf(options_close, index_of_token_end_of_closing) != -1) {
+                    var index_of_opts_opening = token_without_symbols.indexOf(options_open, index_of_token_end_of_closing);
+                    var index_of_opts_end_of_closing = token_without_symbols.indexOf(options_close, index_of_opts_opening) + options_close.length;
+                    try {
+                        opts = f.extend(opts, JSON.parse(token_without_symbols.substring(index_of_opts_opening, index_of_opts_end_of_closing)));
+                        token_without_symbols = token_without_symbols.substring(0, index_of_token_end_of_closing);
+                    } catch (e) {
+                    }
+                }
+            }
+    
+            var translated_token = _translate(token_without_symbols, opts);
+            translated = translated.replace(token, f.regexReplacementEscape(translated_token));
+        }
+        return translated;
+    }
+    
+    function hasContext(options) {
+        return (options.context && (typeof options.context == 'string' || typeof options.context == 'number'));
+    }
+    
+    function needsPlural(options, lng) {
+        return (options.count !== undefined && typeof options.count != 'string'/* && pluralExtensions.needsPlural(lng, options.count)*/);
+    }
+    
+    function needsIndefiniteArticle(options) {
+        return (options.indefinite_article !== undefined && typeof options.indefinite_article != 'string' && options.indefinite_article);
+    }
+    
+    function exists(key, options) {
+        options = options || {};
+    
+        var notFound = _getDefaultValue(key, options)
+            , found = _find(key, options);
+    
+        return found !== undefined || found === notFound;
+    }
+    
+    function translate(key, options) {
+        if (!initialized) {
+            f.log('i18next not finished initialization. you might have called t function before loading resources finished.')
+    
+            if (options && options.defaultValue) {
+                return options.detaultValue;
+            } else {
+                return '';
+            }
+        };
+        replacementCounter = 0;
+        return _translate.apply(null, arguments);
+    }
+    
+    function _getDefaultValue(key, options) {
+        return (options.defaultValue !== undefined) ? options.defaultValue : key;
+    }
+    
+    function _injectSprintfProcessor() {
+    
+        var values = [];
+    
+        // mh: build array from second argument onwards
+        for (var i = 1; i < arguments.length; i++) {
+            values.push(arguments[i]);
+        }
+    
+        return {
+            postProcess: 'sprintf',
+            sprintf:     values
+        };
+    }
+    
+    function _translate(potentialKeys, options) {
+        if (typeof options !== 'undefined' && typeof options !== 'object') {
+            if (o.shortcutFunction === 'sprintf') {
+                // mh: gettext like sprintf syntax found, automatically create sprintf processor
+                options = _injectSprintfProcessor.apply(null, arguments);
+            } else if (o.shortcutFunction === 'defaultValue') {
+                options = {
+                    defaultValue: options
+                }
+            }
+        } else {
+            options = options || {};
+        }
+    
+        if (typeof o.defaultVariables === 'object') {
+            options = f.extend({}, o.defaultVariables, options);
+        }
+    
+        if (potentialKeys === undefined || potentialKeys === null || potentialKeys === '') return '';
+    
+        if (typeof potentialKeys === 'number') {
+            potentialKeys = String(potentialKeys);
+        }
+    
+        if (typeof potentialKeys === 'string') {
+            potentialKeys = [potentialKeys];
+        }
+    
+        var key = potentialKeys[0];
+    
+        if (potentialKeys.length > 1) {
+            for (var i = 0; i < potentialKeys.length; i++) {
+                key = potentialKeys[i];
+                if (exists(key, options)) {
+                    break;
+                }
+            }
+        }
+    
+        var notFound = _getDefaultValue(key, options)
+            , found = _find(key, options)
+            , nsseparator = options.nsseparator || o.nsseparator
+            , lngs = options.lng ? f.toLanguages(options.lng, options.fallbackLng) : languages
+            , ns = options.ns || o.ns.defaultNs
+            , parts;
+    
+        // split ns and key
+        if (key.indexOf(nsseparator) > -1) {
+            parts = key.split(nsseparator);
+            ns = parts[0];
+            key = parts[1];
+        }
+    
+        if (found === undefined && o.sendMissing && typeof o.missingKeyHandler === 'function') {
+            if (options.lng) {
+                o.missingKeyHandler(lngs[0], ns, key, notFound, lngs);
+            } else {
+                o.missingKeyHandler(o.lng, ns, key, notFound, lngs);
+            }
+        }
+    
+        var postProcessorsToApply,
+            postProcessor,
+            j;
+        if (typeof o.postProcess === 'string' && o.postProcess !== '') {
+            postProcessorsToApply = [o.postProcess];
+        } else if (typeof o.postProcess === 'array' || typeof o.postProcess === 'object') {
+            postProcessorsToApply = o.postProcess;
+        } else {
+            postProcessorsToApply = [];
+        }
+    
+        if (typeof options.postProcess === 'string' && options.postProcess !== '') {
+            postProcessorsToApply = postProcessorsToApply.concat([options.postProcess]);
+        } else if (typeof options.postProcess === 'array' || typeof options.postProcess === 'object') {
+            postProcessorsToApply = postProcessorsToApply.concat(options.postProcess);
+        }
+    
+        if (found !== undefined && postProcessorsToApply.length) {
+            for (j = 0; j < postProcessorsToApply.length; j += 1) {
+                postProcessor = postProcessorsToApply[j];
+                if (postProcessors[postProcessor]) {
+                    found = postProcessors[postProcessor](found, key, options);
+                }
+            }
+        }
+    
+        // process notFound if function exists
+        var splitNotFound = notFound;
+        if (notFound.indexOf(nsseparator) > -1) {
+            parts = notFound.split(nsseparator);
+            splitNotFound = parts[1];
+        }
+        if (splitNotFound === key && o.parseMissingKey) {
+            notFound = o.parseMissingKey(notFound);
+        }
+    
+        if (found === undefined) {
+            notFound = applyReplacement(notFound, options);
+            notFound = applyReuse(notFound, options);
+    
+            if (postProcessorsToApply.length) {
+                found = _getDefaultValue(key, options);
+                for (j = 0; j < postProcessorsToApply.length; j += 1) {
+                    postProcessor = postProcessorsToApply[j];
+                    if (postProcessors[postProcessor]) {
+                        found = postProcessors[postProcessor](found, key, options);
+                    }
+                }
+            }
+        }
+    
+        return (found !== undefined) ? found : notFound;
+    }
+    
+    function _find(key, options) {
+        options = options || {};
+    
+        var optionWithoutCount, translated
+            , notFound = _getDefaultValue(key, options)
+            , lngs = languages;
+    
+        if (!resStore) { return notFound; } // no resStore to translate from
+    
+        // CI mode
+        if (lngs[0].toLowerCase() === 'cimode') return notFound;
+    
+        // passed in lng
+        if (options.lngs) lngs = options.lngs;
+        if (options.lng) {
+            lngs = f.toLanguages(options.lng, options.fallbackLng);
+    
+            if (!resStore[lngs[0]]) {
+                var oldAsync = o.getAsync;
+                o.getAsync = false;
+    
+                i18n.sync.load(lngs, o, function(err, store) {
+                    f.extend(resStore, store);
+                    o.getAsync = oldAsync;
+                });
+            }
+        }
+    
+        var ns = options.ns || o.ns.defaultNs;
+        var nsseparator = options.nsseparator || o.nsseparator;
+        if (key.indexOf(nsseparator) > -1) {
+            var parts = key.split(nsseparator);
+            ns = parts[0];
+            key = parts[1];
+        }
+    
+        if (hasContext(options)) {
+            optionWithoutCount = f.extend({}, options);
+            delete optionWithoutCount.context;
+            optionWithoutCount.defaultValue = o.contextNotFound;
+    
+            var contextKey = ns + nsseparator + key + '_' + options.context;
+    
+            translated = translate(contextKey, optionWithoutCount);
+            if (translated != o.contextNotFound) {
+                return applyReplacement(translated, { context: options.context }); // apply replacement for context only
+            } // else continue translation with original/nonContext key
+        }
+    
+        if (needsPlural(options, lngs[0])) {
+            optionWithoutCount = f.extend({ lngs: [lngs[0]]}, options);
+            delete optionWithoutCount.count;
+            optionWithoutCount._origLng = optionWithoutCount._origLng || optionWithoutCount.lng || lngs[0];
+            delete optionWithoutCount.lng;
+            optionWithoutCount.defaultValue = o.pluralNotFound;
+    
+            var pluralKey;
+            if (!pluralExtensions.needsPlural(lngs[0], options.count)) {
+                pluralKey = ns + nsseparator + key;
+            } else {
+                pluralKey = ns + nsseparator + key + o.pluralSuffix;
+                var pluralExtension = pluralExtensions.get(lngs[0], options.count);
+                if (pluralExtension >= 0) {
+                    pluralKey = pluralKey + '_' + pluralExtension;
+                } else if (pluralExtension === 1) {
+                    pluralKey = ns + nsseparator + key; // singular
+                }
+            }
+    
+            translated = translate(pluralKey, optionWithoutCount);
+    
+            if (translated != o.pluralNotFound) {
+                return applyReplacement(translated, {
+                    count: options.count,
+                    interpolationPrefix: options.interpolationPrefix,
+                    interpolationSuffix: options.interpolationSuffix
+                }); // apply replacement for count only
+            } else if (lngs.length > 1) {
+                // remove failed lng
+                var clone = lngs.slice();
+                clone.shift();
+                options = f.extend(options, { lngs: clone });
+                options._origLng = optionWithoutCount._origLng;
+                delete options.lng;
+                // retry with fallbacks
+                translated = translate(ns + nsseparator + key, options);
+                if (translated != o.pluralNotFound) return translated;
+            } else {
+                optionWithoutCount.lng = optionWithoutCount._origLng;
+                delete optionWithoutCount._origLng;
+                translated = translate(ns + nsseparator + key, optionWithoutCount);
+    
+                return applyReplacement(translated, {
+                    count: options.count,
+                    interpolationPrefix: options.interpolationPrefix,
+                    interpolationSuffix: options.interpolationSuffix
+                });
+            }
+        }
+    
+        if (needsIndefiniteArticle(options)) {
+            var optionsWithoutIndef = f.extend({}, options);
+            delete optionsWithoutIndef.indefinite_article;
+            optionsWithoutIndef.defaultValue = o.indefiniteNotFound;
+            // If we don't have a count, we want the indefinite, if we do have a count, and needsPlural is false
+            var indefiniteKey = ns + nsseparator + key + (((options.count && !needsPlural(options, lngs[0])) || !options.count) ? o.indefiniteSuffix : "");
+            translated = translate(indefiniteKey, optionsWithoutIndef);
+            if (translated != o.indefiniteNotFound) {
+                return translated;
+            }
+        }
+    
+        var found;
+        var keyseparator = options.keyseparator || o.keyseparator;
+        var keys = key.split(keyseparator);
+        for (var i = 0, len = lngs.length; i < len; i++ ) {
+            if (found !== undefined) break;
+    
+            var l = lngs[i];
+    
+            var x = 0;
+            var value = resStore[l] && resStore[l][ns];
+            while (keys[x]) {
+                value = value && value[keys[x]];
+                x++;
+            }
+            if (value !== undefined && (!o.showKeyIfEmpty || value !== '')) {
+                var valueType = Object.prototype.toString.apply(value);
+                if (typeof value === 'string') {
+                    value = applyReplacement(value, options);
+                    value = applyReuse(value, options);
+                } else if (valueType === '[object Array]' && !o.returnObjectTrees && !options.returnObjectTrees) {
+                    value = value.join('\n');
+                    value = applyReplacement(value, options);
+                    value = applyReuse(value, options);
+                } else if (value === null && o.fallbackOnNull === true) {
+                    value = undefined;
+                } else if (value !== null) {
+                    if (!o.returnObjectTrees && !options.returnObjectTrees) {
+                        if (o.objectTreeKeyHandler && typeof o.objectTreeKeyHandler == 'function') {
+                            value = o.objectTreeKeyHandler(key, value, l, ns, options);
+                        } else {
+                            value = 'key \'' + ns + ':' + key + ' (' + l + ')\' ' +
+                                'returned an object instead of string.';
+                            f.log(value);
+                        }
+                    } else if (valueType !== '[object Number]' && valueType !== '[object Function]' && valueType !== '[object RegExp]') {
+                        var copy = (valueType === '[object Array]') ? [] : {}; // apply child translation on a copy
+                        f.each(value, function(m) {
+                            copy[m] = _translate(ns + nsseparator + key + keyseparator + m, options);
+                        });
+                        value = copy;
+                    }
+                }
+    
+                if (typeof value === 'string' && value.trim() === '' && o.fallbackOnEmpty === true)
+                    value = undefined;
+    
+                found = value;
+            }
+        }
+    
+        if (found === undefined && !options.isFallbackLookup && (o.fallbackToDefaultNS === true || (o.fallbackNS && o.fallbackNS.length > 0))) {
+            // set flag for fallback lookup - avoid recursion
+            options.isFallbackLookup = true;
+    
+            if (o.fallbackNS.length) {
+    
+                for (var y = 0, lenY = o.fallbackNS.length; y < lenY; y++) {
+                    found = _find(o.fallbackNS[y] + nsseparator + key, options);
+    
+                    if (found || (found==="" && o.fallbackOnEmpty === false)) {
+                        /* compare value without namespace */
+                        var foundValue = found.indexOf(nsseparator) > -1 ? found.split(nsseparator)[1] : found
+                          , notFoundValue = notFound.indexOf(nsseparator) > -1 ? notFound.split(nsseparator)[1] : notFound;
+    
+                        if (foundValue !== notFoundValue) break;
+                    }
+                }
+            } else {
+                options.ns = o.ns.defaultNs;
+                found = _find(key, options); // fallback to default NS
+            }
+            options.isFallbackLookup = false;
+        }
+    
+        return found;
+    }
+    function detectLanguage() {
+        var detectedLng;
+        var whitelist = o.lngWhitelist || [];
+        var userLngChoices = [];
+    
+        // get from qs
+        var qsParm = [];
+        if (typeof window !== 'undefined') {
+            (function() {
+                var query = window.location.search.substring(1);
+                var params = query.split('&');
+                for (var i=0; i<params.length; i++) {
+                    var pos = params[i].indexOf('=');
+                    if (pos > 0) {
+                        var key = params[i].substring(0,pos);
+                        if (key == o.detectLngQS) {
+                            userLngChoices.push(params[i].substring(pos+1));
+                        }
+                    }
+                }
+            })();
+        }
+    
+        // get from cookie
+        if (o.useCookie && typeof document !== 'undefined') {
+            var c = f.cookie.read(o.cookieName);
+            if (c) userLngChoices.push(c);
+        }
+    
+        // get from localStorage
+        if (o.detectLngFromLocalStorage && typeof window !== 'undefined' && window.localStorage) {
+            var lang = f.localStorage.getItem('i18next_lng');
+            if (lang) {
+                userLngChoices.push(lang);
+            }
+        }
+    
+        // get from navigator
+        if (typeof navigator !== 'undefined') {
+            if (navigator.languages) { // chrome only; not an array, so can't use .push.apply instead of iterating
+                for (var i=0;i<navigator.languages.length;i++) {
+                    userLngChoices.push(navigator.languages[i]);
+                }
+            }
+            if (navigator.userLanguage) {
+                userLngChoices.push(navigator.userLanguage);
+            }
+            if (navigator.language) {
+                userLngChoices.push(navigator.language);
+            }
+        }
+    
+        (function() {
+            for (var i=0;i<userLngChoices.length;i++) {
+                var lng = userLngChoices[i];
+    
+                if (lng.indexOf('-') > -1) {
+                    var parts = lng.split('-');
+                    lng = o.lowerCaseLng ?
+                        parts[0].toLowerCase() +  '-' + parts[1].toLowerCase() :
+                        parts[0].toLowerCase() +  '-' + parts[1].toUpperCase();
+                }
+    
+                if (whitelist.length === 0 || whitelist.indexOf(lng) > -1) {
+                    detectedLng = lng;
+                    break;
+                }
+            }
+        })();
+    
+        //fallback
+        if (!detectedLng){
+          detectedLng = o.fallbackLng[0];
+        }
+        
+        return detectedLng;
+    }
+    // definition http://translate.sourceforge.net/wiki/l10n/pluralforms
+    
+    /* [code, name, numbers, pluralsType] */
+    var _rules = [
+        ["ach", "Acholi", [1,2], 1],
+        ["af", "Afrikaans",[1,2], 2],
+        ["ak", "Akan", [1,2], 1],
+        ["am", "Amharic", [1,2], 1],
+        ["an", "Aragonese",[1,2], 2],
+        ["ar", "Arabic", [0,1,2,3,11,100],5],
+        ["arn", "Mapudungun",[1,2], 1],
+        ["ast", "Asturian", [1,2], 2],
+        ["ay", "Aymará", [1], 3],
+        ["az", "Azerbaijani",[1,2],2],
+        ["be", "Belarusian",[1,2,5],4],
+        ["bg", "Bulgarian",[1,2], 2],
+        ["bn", "Bengali", [1,2], 2],
+        ["bo", "Tibetan", [1], 3],
+        ["br", "Breton", [1,2], 1],
+        ["bs", "Bosnian", [1,2,5],4],
+        ["ca", "Catalan", [1,2], 2],
+        ["cgg", "Chiga", [1], 3],
+        ["cs", "Czech", [1,2,5],6],
+        ["csb", "Kashubian",[1,2,5],7],
+        ["cy", "Welsh", [1,2,3,8],8],
+        ["da", "Danish", [1,2], 2],
+        ["de", "German", [1,2], 2],
+        ["dev", "Development Fallback", [1,2], 2],
+        ["dz", "Dzongkha", [1], 3],
+        ["el", "Greek", [1,2], 2],
+        ["en", "English", [1,2], 2],
+        ["eo", "Esperanto",[1,2], 2],
+        ["es", "Spanish", [1,2], 2],
+        ["es_ar","Argentinean Spanish", [1,2], 2],
+        ["et", "Estonian", [1,2], 2],
+        ["eu", "Basque", [1,2], 2],
+        ["fa", "Persian", [1], 3],
+        ["fi", "Finnish", [1,2], 2],
+        ["fil", "Filipino", [1,2], 1],
+        ["fo", "Faroese", [1,2], 2],
+        ["fr", "French", [1,2], 9],
+        ["fur", "Friulian", [1,2], 2],
+        ["fy", "Frisian", [1,2], 2],
+        ["ga", "Irish", [1,2,3,7,11],10],
+        ["gd", "Scottish Gaelic",[1,2,3,20],11],
+        ["gl", "Galician", [1,2], 2],
+        ["gu", "Gujarati", [1,2], 2],
+        ["gun", "Gun", [1,2], 1],
+        ["ha", "Hausa", [1,2], 2],
+        ["he", "Hebrew", [1,2], 2],
+        ["hi", "Hindi", [1,2], 2],
+        ["hr", "Croatian", [1,2,5],4],
+        ["hu", "Hungarian",[1,2], 2],
+        ["hy", "Armenian", [1,2], 2],
+        ["ia", "Interlingua",[1,2],2],
+        ["id", "Indonesian",[1], 3],
+        ["is", "Icelandic",[1,2], 12],
+        ["it", "Italian", [1,2], 2],
+        ["ja", "Japanese", [1], 3],
+        ["jbo", "Lojban", [1], 3],
+        ["jv", "Javanese", [0,1], 13],
+        ["ka", "Georgian", [1], 3],
+        ["kk", "Kazakh", [1], 3],
+        ["km", "Khmer", [1], 3],
+        ["kn", "Kannada", [1,2], 2],
+        ["ko", "Korean", [1], 3],
+        ["ku", "Kurdish", [1,2], 2],
+        ["kw", "Cornish", [1,2,3,4],14],
+        ["ky", "Kyrgyz", [1], 3],
+        ["lb", "Letzeburgesch",[1,2],2],
+        ["ln", "Lingala", [1,2], 1],
+        ["lo", "Lao", [1], 3],
+        ["lt", "Lithuanian",[1,2,10],15],
+        ["lv", "Latvian", [1,2,0],16],
+        ["mai", "Maithili", [1,2], 2],
+        ["mfe", "Mauritian Creole",[1,2],1],
+        ["mg", "Malagasy", [1,2], 1],
+        ["mi", "Maori", [1,2], 1],
+        ["mk", "Macedonian",[1,2],17],
+        ["ml", "Malayalam",[1,2], 2],
+        ["mn", "Mongolian",[1,2], 2],
+        ["mnk", "Mandinka", [0,1,2],18],
+        ["mr", "Marathi", [1,2], 2],
+        ["ms", "Malay", [1], 3],
+        ["mt", "Maltese", [1,2,11,20],19],
+        ["nah", "Nahuatl", [1,2], 2],
+        ["nap", "Neapolitan",[1,2], 2],
+        ["nb", "Norwegian Bokmal",[1,2],2],
+        ["ne", "Nepali", [1,2], 2],
+        ["nl", "Dutch", [1,2], 2],
+        ["nn", "Norwegian Nynorsk",[1,2],2],
+        ["no", "Norwegian",[1,2], 2],
+        ["nso", "Northern Sotho",[1,2],2],
+        ["oc", "Occitan", [1,2], 1],
+        ["or", "Oriya", [2,1], 2],
+        ["pa", "Punjabi", [1,2], 2],
+        ["pap", "Papiamento",[1,2], 2],
+        ["pl", "Polish", [1,2,5],7],
+        ["pms", "Piemontese",[1,2], 2],
+        ["ps", "Pashto", [1,2], 2],
+        ["pt", "Portuguese",[1,2], 2],
+        ["pt_br","Brazilian Portuguese",[1,2], 2],
+        ["rm", "Romansh", [1,2], 2],
+        ["ro", "Romanian", [1,2,20],20],
+        ["ru", "Russian", [1,2,5],4],
+        ["sah", "Yakut", [1], 3],
+        ["sco", "Scots", [1,2], 2],
+        ["se", "Northern Sami",[1,2], 2],
+        ["si", "Sinhala", [1,2], 2],
+        ["sk", "Slovak", [1,2,5],6],
+        ["sl", "Slovenian",[5,1,2,3],21],
+        ["so", "Somali", [1,2], 2],
+        ["son", "Songhay", [1,2], 2],
+        ["sq", "Albanian", [1,2], 2],
+        ["sr", "Serbian", [1,2,5],4],
+        ["su", "Sundanese",[1], 3],
+        ["sv", "Swedish", [1,2], 2],
+        ["sw", "Swahili", [1,2], 2],
+        ["ta", "Tamil", [1,2], 2],
+        ["te", "Telugu", [1,2], 2],
+        ["tg", "Tajik", [1,2], 1],
+        ["th", "Thai", [1], 3],
+        ["ti", "Tigrinya", [1,2], 1],
+        ["tk", "Turkmen", [1,2], 2],
+        ["tr", "Turkish", [1,2], 1],
+        ["tt", "Tatar", [1], 3],
+        ["ug", "Uyghur", [1], 3],
+        ["uk", "Ukrainian",[1,2,5],4],
+        ["ur", "Urdu", [1,2], 2],
+        ["uz", "Uzbek", [1,2], 1],
+        ["vi", "Vietnamese",[1], 3],
+        ["wa", "Walloon", [1,2], 1],
+        ["wo", "Wolof", [1], 3],
+        ["yo", "Yoruba", [1,2], 2],
+        ["zh", "Chinese", [1], 3]
+    ];
+    
+    var _rulesPluralsTypes = {
+        1: function(n) {return Number(n > 1);},
+        2: function(n) {return Number(n != 1);},
+        3: function(n) {return 0;},
+        4: function(n) {return Number(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);},
+        5: function(n) {return Number(n===0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);},
+        6: function(n) {return Number((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);},
+        7: function(n) {return Number(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);},
+        8: function(n) {return Number((n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3);},
+        9: function(n) {return Number(n >= 2);},
+        10: function(n) {return Number(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4) ;},
+        11: function(n) {return Number((n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3);},
+        12: function(n) {return Number(n%10!=1 || n%100==11);},
+        13: function(n) {return Number(n !== 0);},
+        14: function(n) {return Number((n==1) ? 0 : (n==2) ? 1 : (n == 3) ? 2 : 3);},
+        15: function(n) {return Number(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);},
+        16: function(n) {return Number(n%10==1 && n%100!=11 ? 0 : n !== 0 ? 1 : 2);},
+        17: function(n) {return Number(n==1 || n%10==1 ? 0 : 1);},
+        18: function(n) {return Number(n==0 ? 0 : n==1 ? 1 : 2);},
+        19: function(n) {return Number(n==1 ? 0 : n===0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3);},
+        20: function(n) {return Number(n==1 ? 0 : (n===0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);},
+        21: function(n) {return Number(n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0); }
+    };
+    
+    var pluralExtensions = {
+    
+        rules: (function () {
+            var l, rules = {};
+            for (l=_rules.length; l-- ;) {
+                rules[_rules[l][0]] = {
+                    name: _rules[l][1],
+                    numbers: _rules[l][2],
+                    plurals: _rulesPluralsTypes[_rules[l][3]]
+                }
+            }
+            return rules;
+        }()),
+    
+        // you can add your own pluralExtensions
+        addRule: function(lng, obj) {
+            pluralExtensions.rules[lng] = obj;
+        },
+    
+        setCurrentLng: function(lng) {
+            if (!pluralExtensions.currentRule || pluralExtensions.currentRule.lng !== lng) {
+                var parts = lng.split('-');
+    
+                pluralExtensions.currentRule = {
+                    lng: lng,
+                    rule: pluralExtensions.rules[parts[0]]
+                };
+            }
+        },
+    
+        needsPlural: function(lng, count) {
+            var parts = lng.split('-');
+    
+            var ext;
+            if (pluralExtensions.currentRule && pluralExtensions.currentRule.lng === lng) {
+                ext = pluralExtensions.currentRule.rule; 
+            } else {
+                ext = pluralExtensions.rules[parts[f.getCountyIndexOfLng(lng)]];
+            }
+    
+            if (ext && ext.numbers.length <= 1) {
+                return false;
+            } else {
+                return this.get(lng, count) !== 1;
+            }
+        },
+    
+        get: function(lng, count) {
+            var parts = lng.split('-');
+    
+            function getResult(l, c) {
+                var ext;
+                if (pluralExtensions.currentRule && pluralExtensions.currentRule.lng === lng) {
+                    ext = pluralExtensions.currentRule.rule; 
+                } else {
+                    ext = pluralExtensions.rules[l];
+                }
+                if (ext) {
+                    var i;
+                    if (ext.noAbs) {
+                        i = ext.plurals(c);
+                    } else {
+                        i = ext.plurals(Math.abs(c));
+                    }
+                    
+                    var number = ext.numbers[i];
+                    if (ext.numbers.length === 2 && ext.numbers[0] === 1) {
+                        if (number === 2) { 
+                            number = -1; // regular plural
+                        } else if (number === 1) {
+                            number = 1; // singular
+                        }
+                    }//console.log(count + '-' + number);
+                    return number;
+                } else {
+                    return c === 1 ? '1' : '-1';
+                }
+            }
+                        
+            return getResult(parts[f.getCountyIndexOfLng(lng)], count);
+        }
+    
+    };
+    var postProcessors = {};
+    var addPostProcessor = function(name, fc) {
+        postProcessors[name] = fc;
+    };
+    // sprintf support
+    var sprintf = (function() {
+        function get_type(variable) {
+            return Object.prototype.toString.call(variable).slice(8, -1).toLowerCase();
+        }
+        function str_repeat(input, multiplier) {
+            for (var output = []; multiplier > 0; output[--multiplier] = input) {/* do nothing */}
+            return output.join('');
+        }
+    
+        var str_format = function() {
+            if (!str_format.cache.hasOwnProperty(arguments[0])) {
+                str_format.cache[arguments[0]] = str_format.parse(arguments[0]);
+            }
+            return str_format.format.call(null, str_format.cache[arguments[0]], arguments);
+        };
+    
+        str_format.format = function(parse_tree, argv) {
+            var cursor = 1, tree_length = parse_tree.length, node_type = '', arg, output = [], i, k, match, pad, pad_character, pad_length;
+            for (i = 0; i < tree_length; i++) {
+                node_type = get_type(parse_tree[i]);
+                if (node_type === 'string') {
+                    output.push(parse_tree[i]);
+                }
+                else if (node_type === 'array') {
+                    match = parse_tree[i]; // convenience purposes only
+                    if (match[2]) { // keyword argument
+                        arg = argv[cursor];
+                        for (k = 0; k < match[2].length; k++) {
+                            if (!arg.hasOwnProperty(match[2][k])) {
+                                throw(sprintf('[sprintf] property "%s" does not exist', match[2][k]));
+                            }
+                            arg = arg[match[2][k]];
+                        }
+                    }
+                    else if (match[1]) { // positional argument (explicit)
+                        arg = argv[match[1]];
+                    }
+                    else { // positional argument (implicit)
+                        arg = argv[cursor++];
+                    }
+    
+                    if (/[^s]/.test(match[8]) && (get_type(arg) != 'number')) {
+                        throw(sprintf('[sprintf] expecting number but found %s', get_type(arg)));
+                    }
+                    switch (match[8]) {
+                        case 'b': arg = arg.toString(2); break;
+                        case 'c': arg = String.fromCharCode(arg); break;
+                        case 'd': arg = parseInt(arg, 10); break;
+                        case 'e': arg = match[7] ? arg.toExponential(match[7]) : arg.toExponential(); break;
+                        case 'f': arg = match[7] ? parseFloat(arg).toFixed(match[7]) : parseFloat(arg); break;
+                        case 'o': arg = arg.toString(8); break;
+                        case 's': arg = ((arg = String(arg)) && match[7] ? arg.substring(0, match[7]) : arg); break;
+                        case 'u': arg = Math.abs(arg); break;
+                        case 'x': arg = arg.toString(16); break;
+                        case 'X': arg = arg.toString(16).toUpperCase(); break;
+                    }
+                    arg = (/[def]/.test(match[8]) && match[3] && arg >= 0 ? '+'+ arg : arg);
+                    pad_character = match[4] ? match[4] == '0' ? '0' : match[4].charAt(1) : ' ';
+                    pad_length = match[6] - String(arg).length;
+                    pad = match[6] ? str_repeat(pad_character, pad_length) : '';
+                    output.push(match[5] ? arg + pad : pad + arg);
+                }
+            }
+            return output.join('');
+        };
+    
+        str_format.cache = {};
+    
+        str_format.parse = function(fmt) {
+            var _fmt = fmt, match = [], parse_tree = [], arg_names = 0;
+            while (_fmt) {
+                if ((match = /^[^\x25]+/.exec(_fmt)) !== null) {
+                    parse_tree.push(match[0]);
+                }
+                else if ((match = /^\x25{2}/.exec(_fmt)) !== null) {
+                    parse_tree.push('%');
+                }
+                else if ((match = /^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(_fmt)) !== null) {
+                    if (match[2]) {
+                        arg_names |= 1;
+                        var field_list = [], replacement_field = match[2], field_match = [];
+                        if ((field_match = /^([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) {
+                            field_list.push(field_match[1]);
+                            while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') {
+                                if ((field_match = /^\.([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) {
+                                    field_list.push(field_match[1]);
+                                }
+                                else if ((field_match = /^\[(\d+)\]/.exec(replacement_field)) !== null) {
+                                    field_list.push(field_match[1]);
+                                }
+                                else {
+                                    throw('[sprintf] huh?');
+                                }
+                            }
+                        }
+                        else {
+                            throw('[sprintf] huh?');
+                        }
+                        match[2] = field_list;
+                    }
+                    else {
+                        arg_names |= 2;
+                    }
+                    if (arg_names === 3) {
+                        throw('[sprintf] mixing positional and named placeholders is not (yet) supported');
+                    }
+                    parse_tree.push(match);
+                }
+                else {
+                    throw('[sprintf] huh?');
+                }
+                _fmt = _fmt.substring(match[0].length);
+            }
+            return parse_tree;
+        };
+    
+        return str_format;
+    })();
+    
+    var vsprintf = function(fmt, argv) {
+        argv.unshift(fmt);
+        return sprintf.apply(null, argv);
+    };
+    
+    addPostProcessor("sprintf", function(val, key, opts) {
+        if (!opts.sprintf) return val;
+    
+        if (Object.prototype.toString.apply(opts.sprintf) === '[object Array]') {
+            return vsprintf(val, opts.sprintf);
+        } else if (typeof opts.sprintf === 'object') {
+            return sprintf(val, opts.sprintf);
+        }
+    
+        return val;
+    });
+    // public api interface
+    i18n.init = init;
+    i18n.isInitialized = isInitialized;
+    i18n.setLng = setLng;
+    i18n.preload = preload;
+    i18n.addResourceBundle = addResourceBundle;
+    i18n.hasResourceBundle = hasResourceBundle;
+    i18n.getResourceBundle = getResourceBundle;
+    i18n.addResource = addResource;
+    i18n.addResources = addResources;
+    i18n.removeResourceBundle = removeResourceBundle;
+    i18n.loadNamespace = loadNamespace;
+    i18n.loadNamespaces = loadNamespaces;
+    i18n.setDefaultNamespace = setDefaultNamespace;
+    i18n.t = translate;
+    i18n.translate = translate;
+    i18n.exists = exists;
+    i18n.detectLanguage = f.detectLanguage;
+    i18n.pluralExtensions = pluralExtensions;
+    i18n.sync = sync;
+    i18n.functions = f;
+    i18n.lng = lng;
+    i18n.dir = dir;
+    i18n.addPostProcessor = addPostProcessor;
+    i18n.applyReplacement = f.applyReplacement;
+    i18n.options = o;
+    i18n.noConflict = noConflict;
+
+})(typeof exports === 'undefined' ? window : exports);

File diff suppressed because it is too large
+ 1 - 0
public/javascripts/jquery-2.1.4.min.js


+ 133 - 0
public/javascripts/link_parser.js

@@ -0,0 +1,133 @@
+function parse_link(data) {
+  if (data == null) {
+    return "";
+  }
+
+  var html = data;
+  var recommendedWidth = 400;
+  var recommendedHeight = 300;
+  var extraViewClasses = "";
+  var sourceLink = null;
+  var plainText = false;
+  var genericEmbedMatcher = /embed\:(https?\:\/\/[^ <]+)/;
+  var genericUriMatcher = /(https?\:\/\/[^ <]+)/;
+  var type = "unknown";
+  var provider_name = "unknown";
+
+  isDataFileUrl = function(url) {
+    var file, suffix;
+    try {
+      if (url.split("/").length < 4) {
+        return false;
+      }
+      file = _.last(url.split("/"));
+      if (file.indexOf(".") < 0) {
+        return false;
+      }
+      suffix = _.last(file.split("."));
+      if (!suffix) {
+        return false;
+      }
+      if (_.include(["png", "jpg", "jpeg", "gif", "zip", "rar", "7z", "tar", "tgz", "gz", "xls", "xlsx", "doc", "docx", "ppt", "pptx", "mp3", "ogg", "oga", "ogv", "pdf", "dmg", "exe", "iso", "dxf", "ipa", "mov", "wmv", "wma", "wav", "aiff", "mp4", "m4a", "prg", "bin", "dat", "psd", "ai", "eps", "key"], suffix)) {
+        return true;
+      }
+    } catch (_error) {}
+    return false;
+  };
+
+  if (m = data.match(genericEmbedMatcher)) {
+    embedUri = m[1];
+    html = "<iframe width='100%' height='100%' src=\"" + embedUri + "\" seamless=\"1\" allowfullscreen=\"1\"></iframe>";
+    recommendedWidth = 640 / 2;
+    recommendedHeight = 390 / 2;
+    sourceLink = embedUri;
+    extraViewClasses = "external-embed";
+  } else if (data.match(/http/) && data.replace(/[^<]/g, "").length < 3) {
+    
+    youtubeMatcher = /youtube\.com\/.*v=([^&<]+)/;
+    youtubeMatcher2 = /youtu\.be\/([^&<]+)/;
+    soundcloudMatcher = /soundcloud\.com\/([^<]+)/;
+    vimeoMatcher = /vimeo.com\/([^<]*)/;
+    dailyMotionMatcher = /dailymotion.com\/video\/([^<]*)/;
+    googleMapsMatcher = /google.com\/maps\?([^<]*)/;
+    spacedeckMatcher = new RegExp(location.host + "\/(spaces|folders)\/([0-9a-f]{24})");
+
+    if (m = data.match(youtubeMatcher) || (m = data.match(youtubeMatcher2))) {
+      videoId = m[1];
+      html = "<iframe src=\"https://www.youtube.com/embed/" + videoId + "?html5=1&rel=0&showinfo=0&autohide=1\" frameborder=\"0\" allowfullscreen=\"1\"></iframe>";
+      recommendedWidth = 640 / 2;
+      recommendedHeight = 390 / 2;
+      provider_name = "youtube";
+      type = "video";
+
+    } else if (m = data.match(dailyMotionMatcher)) {
+      videoId = m[1];
+      html = "<iframe src=\"https://www.dailymotion.com/embed/video/" + videoId + "\" frameborder=\"0\"></iframe>";
+      recommendedWidth = 536 / 2;
+      recommendedHeight = 302 / 2;
+
+      provider_name = "dailymotion";
+      type = "video";
+
+    } else if (m = data.match(vimeoMatcher)) {
+      videoId = m[1];
+      html = "<iframe src=\"https://player.vimeo.com/video/" + videoId + "\" frameborder=\"0\"></iframe>";
+      recommendedWidth = 536 / 2;
+      recommendedHeight = 302 / 2;
+
+      provider_name = "vimeo";
+      type = "video";
+
+    } else if (m = data.match(soundcloudMatcher)) {
+      var scurl = "https://" + m[0];
+      var url;
+      if (m[0].indexOf("soundcloud.com/player")>=0) {
+        url = "https://w." + m[0];
+      } else {
+        url = "https://w.soundcloud.com/player/?url="+encodeURI(scurl);
+      }
+
+      html = "<iframe scrolling=\"no\" frameborder=\"no\" src=\"" + url + "\"></iframe>";
+      recommendedWidth = 720 / 2;
+      recommendedHeight = 184;
+      sourceLink = scurl;
+
+      provider_name = "soundcloud";
+      type = "audio";
+
+    } else if ((m = data.match(googleMapsMatcher))) {
+      mapsParams = m[1];
+      html = "<iframe src=\"https://maps-api-ssl.google.com/maps?" + mapsParams + "\" seamless=\"1\" allowfullscreen=\"1\"></iframe>";
+      recommendedWidth = 640 / 2;
+      recommendedHeight = 390 / 2;
+
+      provider_name = "google";
+      type = "map";
+    } else if ((m = data.match(genericUriMatcher)) && !isDataFileUrl(m[1])) {
+      uri = m[1];
+      grabUri = uri;
+      endPoint = "/api/webgrabber/" + (encodeURIComponent(btoa(grabUri)));
+      html = data.replace(uri, " <img src=\"" + endPoint + "\" title=\"" + uri + "\"/> ");
+      recommendedWidth = 300;
+      recommendedHeight = 300;
+      sourceLink = uri;
+    } else {
+      plainText = true;
+    }
+  } else {
+    plainText = true;
+  }
+  if (plainText) {
+    // replace links with clickable links
+    return null;
+  }
+  result = {
+    html: html,
+    thumbnail_width: recommendedWidth,
+    thumbnail_height: recommendedHeight,
+    type: type,
+    provider_name: provider_name,
+    url: sourceLink
+  };
+  return result;
+};

+ 946 - 0
public/javascripts/locales.js

@@ -0,0 +1,946 @@
+window.locales = {};
+window.locales.en = {};
+window.locales.de = {};
+window.locales.fr = {};
+window.locales.en.translation = 
+{
+	"ok": "OK",
+	"cancel": "Cancel",
+	"close": "Close",
+	"open": "Open",
+	"folder": "Folder",
+	"save": "Save",
+	"saved": "Saved",
+	"created": "created",
+	"duplicate": "Duplicate",
+	"delete": "Delete",
+	"remove": "Remove",
+	"set": "set",
+	"reset": "reset",
+	"thanks": "Thanks",
+	"share": "Share",
+	"signup": "Sign Up",
+	"login": "Log in",
+	"logout": "Log out",
+	"email": "Email Address",
+	"password": "Password",
+	"width": "Width",
+	"height": "Height",
+	"nick": "Name",
+	"role": "Role",
+	"members": "Members",
+	"actions": "Actions",
+	"or": "or",
+	"you": "you",
+	"via": "via",
+	"by": "by",
+	"zero": "Zero",
+	"page": "Page",
+	"new": "New",
+	"copy": "Copy",
+	"home": "Home",
+	"owner": "Owner",
+	"space": "Space",
+	"second": "Second",
+	"not_found": "Not Found.",
+	"untitled_space": "Untitled Space",
+	"untitled_folder": "Untitled Folder",
+	"untitled": "untitled",
+	"sure": "Are you sure?",
+	"specify": "Please Specify",
+	"confirm": "Please Confirm",
+	"signup_google": "Sign In with Google",
+	"error_unknown_email": "This email/password combination is unknown. Try login with Google.",
+	"error_password_confirmation": "The entered passwords don't match.",
+	"error_domain_blocked": "Your domain is blocked.",
+	"error_user_email_already_used": "This email address is already in use.",
+	"support": "Spacedeck Support",
+	"offline": "Offline. Click for more.",
+	"error": "Sorry, but something went wrong. Please contact support@spacedeck.com",
+	"welcome": "Welcome",
+	"claim": "Your digital Whiteboard.",
+	"trynow": "Try now.",
+	"about": "About us",
+	"terms": "Terms",
+	"contact": "Contact",
+	"privacy": "Privacy",
+	"business_adress": "Business Adress",
+	"post_adress": "Post Adress",
+	"phone": "Phone",
+	"ceo": "Managing Director",
+	"name": "Name",
+	"confirm_subject": "Spacedeck Email Confirmation",
+	"confirm_body": "Thank you for signing up at Spacedeck.\nPlease click on the following link to confirm your email address.\n",
+	"confirm_action": "Confirm Now",
+	"team_invite_membership_subject": "Team Invitation for %s",
+	"team_invite_membership_body": "You have been invited to %s on Spacedeck. Please click on the following link to accept the invitation.",
+	"team_invite_user_body": "You have been invited to %s on Spacedeck.\nYour temporary password is \"%s\".\nPlease click on the following link to accept the invitation.",
+	"team_invite_admin_body": "%s was invited for your team: %s. The temporary password is \"%s\".",
+	"team_invite_membership_acction": "Accept",
+	"team_new_member_subject": "New Team Member for %s signed up",
+	"team_new_member_body": "%s just joined Team %s on Spacedeck.",
+	"space_invite_membership_subject": "%s invited you to a Space %s ",
+	"space_invite_membership_body": "You have been invited by %s to join a Space %s on Spacedeck. Please click on the following link to accept the invitation.",
+	"space_invite_membership_action": "Accept",
+	"folder_invite_membership_subject": "Space",
+	"folder_invite_membership_body": "You have been invited to a Team on Spacedeck. Please click on the following link to accept the invitation.",
+	"folder_invite_membership_acction": "Accept",
+	"login_google": "Login With Google",
+	"save_changes": "Save Changes",
+	"upgrade": "Upgrade",
+	"upgrade_now": "Upgrade Now",
+	"create_space": "Create Space",
+	"create_folder": "Create Folder",
+	"email_unconfirmed": "Email Unconfirmed",
+	"confirmation_sent": "Email Sent",
+	"folder_filter": "Filter",
+	"sort_by": "Sort by",
+	"last_modified": "Last Modified",
+	"last_opened": "Last Opened",
+	"title": "Title",
+	"edit_team": "Edit Team",
+	"edit_account": "Edit Account",
+	"log_out": "Log Out",
+	"no_spaces_yet": "Welcome! You can create Spaces and Folders here using the buttons in the top left corner.",
+	"new_folder_title": "New title for folder",
+	"folder_settings": "Folder Settings",
+	"upload_cover_image": "Upload Cover Image",
+	"spacedeck_pro_ad_folders": "With Spacedeck Pro, you can organize an unlimited amount of Spaces in Folders and manage access controls for each Folder. Would you like to learn more about Pro features?",
+	"spacedeck_pro_ad_versions": "With Spacedeck Pro, you can save unlimited versions of each Space to track your progress or keep snapshots safe. Would you like to learn more about Pro features?",
+	"spacedeck_pro_ad_pdf": "With Spacedeck Pro, you can export your Spaces as crisp PDFs for archiving, mailing around, or printing. Do you want to learn more about Pro features?",
+	"spacedeck_pro_ad_zip": "With Spacedeck Pro, you can export the contents of a Space as a ZIP package. Do you want to learn more about Pro features?",
+	"spacedeck_pro_ad_colors": "With Spacedeck Pro, you can mix your own colors using a professional color picker.",
+	"profile_caption": "Profile",
+	"upload_avatar": "Upload Avatar",
+	"uploading_avatar": "Uploading Avatar…",
+	"avatar_dimensions": "Recommended dimensions: 200×200 pixels.",
+	"profile_name": "Name",
+	"profile_email": "Email Address",
+	"send_again": "Send Again",
+	"confirmation_sent_long": "Email confirmation link sent. Please check your inbox.",
+	"confirmation_sent_another": "Another confirmation link sent.",
+	"confirmation_sent_dialog_text": "We sent you an email explaining how to confirm your email address.",
+	"payment_caption": "Payment",
+	"language_caption": "Language",
+	"notifications_caption": "Notifications",
+	"notifications_option_chat": "Inform me via email about new comments",
+	"notifications_option_spaces": "Send me a daily digest of what happened in my Spaces and Folders",
+	"password_caption": "Password",
+	"current_password": "Current Password",
+	"new_password": "New Password",
+	"verify_password": "Verify Password",
+	"change_password": "Change Password",
+	"reset_password": "Reset Password",
+	"terminate_caption": "Delete Account",
+	"terminate_warning": "If you delete your account, all Spaces, Folders and Messages including all content you and other people created in your Spaces will be destroyed.",
+	"terminate_warning2": "This cannot be undone.",
+	"terminate_reason": "Message",
+	"terminate_reason_caption": "Help us improve by sharing your reasons for cancelling.",
+	"terminate_terminate": "Terminate",
+	"space_blank1": "Welcome to a fresh new Space!",
+	"space_blank2": "Drop files, paste links",
+	"space_blank3": "or use the tools below",
+	"space_blank4": "to fill this Space with content.",
+	"draft": "Draft",
+	"publish": "Publish",
+	"published": "Published",
+	"save_version": "Save Version",
+	"version_saved": "Version Saved",
+	"post": "Post Message",
+	"chat_invite_cta1": "Collaboration is fun!",
+	"chat_invite_cta2": "Why not ",
+	"chat_invite_cta3": "invite some people",
+	"chat_invite_cta4": "to work with you?",
+	"chat_message_placeholder": "Write your message…",
+	"view": "View",
+	"edit": "Edit",
+	"present": "Present",
+	"chat": "Chat",
+	"meta": "Meta",
+	"tool_search": "Search",
+	"tool_upload": "Upload",
+	"tool_text": "Text",
+	"tool_shape": "Shape",
+	"tool_zones": "Zones",
+	"tool_canvas": "Canvas",
+	"search_media": "Search media…",
+	"type_here": "Type here",
+	"text_formats": "Formats",
+	"format_p": "Paragraph",
+	"format_bullets": "Bullet List",
+	"format_numbers": "Numbered List",
+	"format_h1": "Headline 1",
+	"format_h2": "Headline 2",
+	"format_h3": "Headline 3",
+	"font_size": "Font Size",
+	"line_height": "Line Height",
+	"tool_align": "Align",
+	"tool_styles": "Styles",
+	"tool_bullets": "Bullets",
+	"tool_numbers": "Numbers",
+	"color_fill": "Fill",
+	"color_stroke": "Stroke",
+	"color_text": "Text",
+	"tool_type": "Type",
+	"tool_box": "Box",
+	"tool_link": "Link",
+	"tool_layout": "Layout",
+	"tool_options": "Options",
+	"tool_stroke": "Stroke",
+	"tool_delete": "Delete",
+	"tool_lock": "Lock",
+	"tool_copy": "Copy",
+	"stack": "Stack",
+	"tool_circle": "Circle",
+	"tool_hexagon": "Hexagon",
+	"tool_square": "Square",
+	"tool_diamond": "Diamond",
+	"tool_bubble": "Bubble",
+	"tool_cloud": "Cloud",
+	"tool_burst": "Burst",
+	"tool_star": "Star",
+	"tool_heart": "Heart",
+	"tool_scribble": "Scribble",
+	"tool_line": "Line",
+	"tool_arrow": "Arrow",
+	"search_media_placeholder": "Search web media…",
+	"add_zone": "New Zone",
+	"palette": "Palette",
+	"picker": "Picker",
+	"background_image_caption": "Image",
+	"background_color_caption": "Color",
+	"upload_background_caption": "Click to upload a background image",
+	"upload_background": "Upload Background",
+	"access_caption": "Access",
+	"versions_caption": "Versions",
+	"info_caption": "Info",
+	"mode_private": "Private: Only members can view or edit",
+	"mode_public": "Public: Anyone with the link can view",
+	"invite_collaborators": "Invite Collaborators",
+	"revoke_access": "Revoke Access",
+	"invite": "Send Invitations",
+	"invitee_email_address": "Email address of new member",
+	"optional_message": "Optional message",
+	"role_viewer": "Viewer",
+	"role_editor": "Editor",
+	"role_admin": "Admin",
+	"new_space_title": "New title for Space",
+	"team": "Team",
+	"search": "Search",
+	"search_no_results": "search_no_results",
+	"search_clear": "search_clear",
+	"rename": "Rename",
+	"mobile": "mobile",
+	"image": "image",
+	"tool_filter": "filter",
+	"canel": "canel",
+	"invite_membership_action": "invite_membership_action",
+	"viewer": "viewer",
+	"editor": "editor",
+	"admin": "admin",
+	"logging_in": "logging in",
+	"password_confirmation": "Password Confirmation",
+	"confirm_again": "We sent you an email explaining how to confirm your email address.",
+	"confirmed": "Your Account was confirmed successfully. Thank you.",
+	"signing_up": "Signing up",
+	"password_check_inbox": "Please check your inbox",
+	"new_space": "New Space",
+	"tool_more": "More",
+	"what_is_your_name": "Welcome to %s! Please choose a username.",
+	"lang": "en",
+	"landing_title": "Your Whiteboard on the Web.",
+	"landing_claim": "Spacedeck lets you easily combine all kinds of media on virtual whiteboards: Text notes, photos, web links, even videos and audio recordings. ",
+	"landing_example": "People use Spacedeck to organize their ideas, in teams to see whole projects at a glance, or in schools and universities for richer, connected learning experiences.",
+	"spaces": "My Spaces",
+	"access_editor_link": "Instant Edit Link",
+	"access_editor_link_desc": "Give this link to anyone who should be able to instantly edit this Space, no account required: ",
+	"access_anonymous_edit_blocking": "Anonymous Editors may only change their own items",
+	"access_current_members": "Current Members",
+	"access_new_members": "Invite New Members",
+	"access_no_members": "Members of this Space will show up here.",
+	"comments": "comments",
+	"landing_customers": "Trusted by Thousands.",
+	"landing_features_title": "A Breeze To Use.",
+	"landing_features_text": "The new Spacedeck 5 has a streamlined, beautiful user interface that makes your work easier and more fun than ever before – while giving you even more powerful features:",
+	"landing_features_1": "<b>Drag & drop</b> images, videos and audio from your computer or the web",
+	"landing_features_2": "<b>Write and format tex</b>t with full control over fonts, colors and style",
+	"landing_features_3": "<b>Draw, annotate and highlight</b> with included graphical shapes",
+	"landing_features_4": "Turn your board into a <b>zooming presentation</b>",
+	"landing_features_5": "<b>Collaborate and chat</b> in realtime with teammates, students or friends.",
+	"landing_features_6": "<b>Share Spaces</b> on the web or via email",
+	"landing_features_7": "<b>Export your work</b> as printable PDF or ZIP",
+	"landing_pricing": "Incredibly Affordable.",
+	"landing_pricing_lite": "Free/Personal Use",
+	"landing_pricing_lite_text": "The basic, well-rounded version for collecting pictures and keeping notes.",
+	"landing_pricing_pro_features_list": "<ul><li>Unlimited Spaces</li><li>Folder Structures</li><li>PDF and ZIP Export</li><li>No Watermarks</li><li>Custom Backgrounds</li><li>Activity History</li><li>20 GB Storage</li><ul>",
+	"landing_pricing_pro": "€4,90/User/Mo. <br><small>or 49,90/User/Year</small>",
+	"landing_pricing_pro_text": "Turbocharged with all the power you expect.",
+	"landing_pricing_pro_features": "Turbocharged with all the power you expect.",
+	"welcome_subject": "Welcome to Spacedeck",
+	"welcome_body": "Hello!\nThank you for signing up at Spacedeck.<br>We hope you will enjoy working in Spaces.<br>Remember, your account includes unlimited collaborators. Feel free to share your Spaces with friends and colleagues all over the world.",
+	"invite_emails": "Email addresses (Comma separated)",
+	"history_recently_updated": "Recently Updated",
+	"history_recently_empty": "Nothing has happened yet.",
+	"parent_folder": "parent_folder",
+	"created_by": "Created by",
+	"last_updated": "Last updated",
+	"feedback_sent": "Thanks for your feedback!",
+	"role_member": "Member",
+	"team_invite_membership_action": "Accept invitation",
+	"space_message_subject": "New Message in Space %s",
+	"space_message_body": "%s wrote in %s: \n",
+	"pro_ad_history_headline": "When you upgrade to Spacedeck Pro, you will see a history of recent updates across all your (shared) Spaces here.",
+	"password_reset_subject": "Reset Password for Spacedeck",
+	"password_reset_body": "You requested a reset of your Spacedeck password.\nPlease click on the following link to set a new password.",
+	"password_reset_action": "Reset Now",
+	"was_offline": "The connection to Spacedeck was interrupted. If you have unsaved work, please keep this browser tab open until the connection is re-established, then touch the unsaved objects again.",
+	"subscription_failed_user_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_user_body": "Unfortunately, we could not process your Payment-method. You can easly create a new payment method including PayPal in your account settings.",
+	"subscription_failed_team_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_team_body": "Unfortunately, we could not process your Payment-method for your Team-Account. Please fix your payment method asap.",
+	"team_name": "Team Name",
+	"subdomain": "Subdomain",
+	"team_adresses": "Email adresses",
+	"add": "add",
+	"invited": "invited",
+	"duplicate_destination": "Into which folder do you want to duplicate this Space?",
+	"duplicate_confirm": "Duplicate %s into %s?",
+	"duplicate_success": "%s was duplicated into %s.",
+	"goto_space": "Go to Space %s",
+	"goto_folder": "Go to Folder %s",
+	"stay_here": "Stay here",
+	"sharing": "sharing",
+	"list": "Export List",
+	"link": "link",
+	"download_space": "Download Space",
+	"type": "Type",
+	"download": "download",
+	"Previous Zone": "Previous Zone",
+	"Next Zone": "Next Zone",
+	"promote": "promote",
+	"demote": "demote"
+}
+window.locales.de.translation = 
+{
+	"lang": "de",
+	"ok": "OK",
+	"cancel": "Abbrechen",
+	"close": "Schließen",
+	"open": "Öffnen",
+	"folder": "Ordner",
+	"duplicate": "Duplizieren",
+	"save": "Speichern",
+	"saved": "Gespeichert",
+	"created": "Erstellt",
+	"delete": "Löschen",
+	"remove": "Entfernen",
+	"set": "Übernehmen",
+	"reset": "Zurücksetzen",
+	"thanks": "Danke",
+	"share": "Teilen",
+	"signup": "Registrieren",
+	"login": "Anmelden",
+	"logout": "Abmelden",
+	"email": "E-Mail-Adresse",
+	"password": "Passwort",
+	"width": "Breite",
+	"height": "Höhe",
+	"nick": "Benutzername",
+	"role": "Rolle",
+	"members": "Mitglieder",
+	"actions": "Aktionen",
+	"or": "oder",
+	"you": "du",
+	"via": "via",
+	"by": "von",
+	"new": "Neu",
+	"zero": "Null",
+	"page": "Seite",
+	"copy": "Kopie",
+	"home": "Übersicht",
+	"owner": "Besitzer",
+	"space": "Space",
+	"second": "Sekunde",
+	"not_found": "Nicht Gefunden.",
+	"untitled_space": "Unbenannter Space",
+	"untitled_folder": "Unbenannter Order",
+	"untitled": "Unbenannter",
+	"sure": "Bist du sicher?",
+	"specify": "Bitte spezifiziere",
+	"confirm": "Bitte bestätige",
+	"signup_google": "Mit Google anmelden",
+	"error_unknown_email": "Unbekannte Kombination von Email und Passwort. Oder versuche dich mit Google anzumelden.",
+	"error_password_confirmation": "Die beiden Passwörter stimmen nicht überein.",
+	"error_domain_blocked": "Diese Domain ist gesperrt.",
+	"error_user_email_already_used": "Diese Email-Adresse ist bereits registriert.",
+	"support": "Spacedeck-Support",
+	"offline": "Verbindungsverlust. Mehr Infos hier.",
+	"error": "Entschuldigung, etwas ist schiefgegangen. Bitte kontaktiere support@spacedeck.com",
+	"welcome": "Willkommen",
+	"claim": "Dein digitales Whiteboard.",
+	"trynow": "Jetzt probieren.",
+	"about": "Über uns",
+	"terms": "AGBs",
+	"contact": "Kontakt",
+	"privacy": "Privatsphäre",
+	"post_adress": "Postadresse",
+	"phone": "Phone",
+	"business_address": "business_address",
+	"ceo": "Geschäftsführer",
+	"business_adress": "business_adress",
+	"title": "Titel",
+	"name": "Name",
+	"confirm_subject": "E-Mail Bestätigung für Spacedeck",
+	"confirm_body": "Danke, dass du dich bei Spacedeck angemeldet hast.\nBitte klicke auf den folgenden Link, um deine E-Mail Adresse zu bestätigen.\n ",
+	"confirm_action": "E-Mail Bestätigen",
+	"team_invite_membership_subject": "Einladung zu %s auf Spacedeck",
+	"team_invite_membership_body": "Du wurdest zu %s auf Spacedeck eingeladen. \n Bitte klicke auf den folgenden Link, um die Einladung anzunehmen.",
+	"team_invite_user_body": "Du wurdest zu %s auf Spacedeck eingeladen. Dein temporäres Passwort ist \"%s\".\n Bitte klicke auf den folgenden Link, um die Einladung anzunehmen.",
+	"team_invite_admin_body": " %s wurde zu %s auf Spacedeck eingeladen. Das temporäres Passwort ist \"%s\".",
+	"team_invite_membership_action": "Annehmen",
+	"team_new_member_subject": "Neues Team Mitglied",
+	"team_new_member_body": "%s hat gerade seine Einladung zum Team %s angenommen.",
+	"space_invite_membership_subject": "Einladung von %s in Space %s",
+	"space_invite_membership_body": "Du wurdest von %s in den Space '%s' eingeladen.\nBitte klicke auf den folgenden Link um die Einladung anzunehmen.",
+	"space_invite_membership_action": "Annehmen",
+	"folder_invite_membership_subject": "Einladung von %s in Ordner %s",
+	"folder_invite_membership_body": "Du wurdest von %s in den Space '%s' eingeladen.\nBitte klicke auf den folgenden Link um die Einladung anzunehmen.",
+	"folder_invite_membership_action": "Accept",
+	"upgrade": "Upgrade",
+	"upgrade_now": "Jetzt Upgraden",
+	"create_space": "Space Erstellen",
+	"create_folder": "Ordner Erstellen",
+	"email_unconfirmed": "Email Unbestätigt",
+	"confirmation_sent": "Email Versandt",
+	"folder_filter": "Filter",
+	"sort_by": "Reihenfolge",
+	"last_modified": "Zuletzt Geändert",
+	"last_opened": "Zuletzt Geöffnet",
+	"edit_team": "Team Verwalten",
+	"edit_account": "Konto Bearbeiten",
+	"log_out": "Abmelden",
+	"no_spaces_yet": "Du hast noch keine Spaces erstellt.",
+	"new_folder_title": "Neuer Titel für Ordner",
+	"folder_settings": "Ordner-Einstellungen",
+	"upload_cover_image": "Ordnerbild Hochladen",
+	"spacedeck_pro_ad_folders": "Mit Spacedeck Pro kannst du beliebig viele Spaces in Ordnerstrukturen organisieren und für jeden Ordner Zugriffsrechte regeln. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_versions": "Mit Spacedeck Pro kannst du beliebig viele Versionen deines Spaces festhalten und später die Entwicklungsgeschichte nachvollziehen. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_pdf": "Mit Spacedeck Pro kannst du Spaces und sogar ganze Ordner als druckreife PDFs exportieren oder per Mail versenden. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_zip": "Mit Spacedeck Pro kannst du die Inhalte deiner Spaces jederzeit als ZIP-Paket exportieren. Möchtest du mehr über die Pro-Version erfahren?",
+	"spacedeck_pro_ad_colors": "Spacedeck Pro enthält einen Profi-Farbmischer, mit dem du deine eigenen Farben mischen kannst.",
+	"profile_caption": "Profil",
+	"upload_avatar": "Profilbild Hochladen",
+	"uploading_avatar": "Profilbild wird hochgeladen…",
+	"avatar_dimensions": "Bestes Format: 200×200 Pixel.",
+	"profile_name": "Name",
+	"profile_email": "Email-Adresse",
+	"send_again": "Erneut Senden",
+	"confirmation_sent_long": "Email-Bestätigungslink versandt. Bitte überprüfe deine Mails.",
+	"confirmation_sent_another": "Wir haben eine weiteren Bestätigungslink versandt.",
+	"confirmation_sent_dialog_text": "Wir haben dir eine Email geschickt, die erklärt, wie das mit der Bestätigung läuft.",
+	"payment_caption": "Bezahlung",
+	"language_caption": "Sprache",
+	"notifications_caption": "Emails",
+	"notifications_option_chat": "Haltet mich über neue Kommentare auf dem Laufenden.",
+	"notifications_option_spaces": "Schickt mir täglich eine Zusammenfassung über Änderungen an meinen Spaces und Ordnern.",
+	"password_caption": "Passwort",
+	"current_password": "Altes Passwort",
+	"new_password": "Neues Passwort",
+	"verify_password": "Zur Sicherheit nochmal",
+	"change_password": "Passwort Ändern",
+	"reset_password": "Passwort Zurücksetzen",
+	"terminate_caption": "Kündigen",
+	"terminate_warning": "Wenn du kündigst, werden all deine Spaces, Ordner und Nachrichten und alle ihre Inhalte gelöscht.",
+	"terminate_warning2": "Das kann man nicht rückgängig machen.",
+	"terminate_reason": "Kündigungsgrund",
+	"terminate_reason_caption": "Wenn du uns mitteilst, was dich gestört hat, hilft uns das dabei ein besseres Produkt zu machen.",
+	"terminate_terminate": "Wirklich Kündigen",
+	"space_blank1": "Dies ist dein brandneuer, leerer Space!",
+	"space_blank2": "Wirf Dateien rein, paste Web-Links",
+	"space_blank3": "oder nutz die Werkzeuge da unten.",
+	"space_blank4": "Sei kreativ und tob dich aus!",
+	"draft": "Entwurf",
+	"publish": "Veröffentlichen",
+	"published": "Veröffentlicht",
+	"save_version": "Version Speichern",
+	"version_saved": "Version Gespeichert",
+	"post": "Abschicken",
+	"chat_invite_cta1": "Zusammen arbeiten macht Spaß!",
+	"chat_invite_cta2": "Warum ",
+	"chat_invite_cta3": "lädst du nicht ein paar Leute ein",
+	"chat_invite_cta4": "mit denen du dann zusammen arbeiten kannst?",
+	"chat_message_placeholder": "Schreib hier deine Nachricht…",
+	"view": "Ansicht",
+	"edit": "Bearb.",
+	"present": "Präse.",
+	"chat": "Chat",
+	"meta": "Teilen",
+	"tool_search": "Suche",
+	"tool_upload": "Upload",
+	"tool_text": "Text",
+	"tool_shape": "Grafik",
+	"tool_zones": "Zonen",
+	"tool_canvas": "Wand",
+	"search_media": "Medien im Web suchen…",
+	"type_here": "Hier was eingeben",
+	"text_formats": "Formate",
+	"format_p": "Absatz",
+	"format_bullets": "Bullet-Liste",
+	"format_numbers": "Nummerierte Liste",
+	"format_h1": "Überschrift 1",
+	"format_h2": "Überschrift 2",
+	"format_h3": "Überschrift 3",
+	"font_size": "Schriftgröße",
+	"line_height": "Zeilenhöhe",
+	"tool_align": "Bund",
+	"tool_styles": "Stil",
+	"tool_bullets": "Bullets",
+	"tool_numbers": "Zahlen",
+	"color_fill": "Füllung",
+	"color_stroke": "Strich",
+	"color_text": "Text",
+	"tool_type": "Typo",
+	"tool_box": "Box",
+	"tool_link": "Link",
+	"tool_layout": "Layout",
+	"tool_options": "Mehr",
+	"tool_stroke": "Strich",
+	"tool_delete": "Löschen",
+	"tool_lock": "Sperren",
+	"tool_copy": "Kopie",
+	"stack": "Anordnung",
+	"tool_circle": "Kreis",
+	"tool_hexagon": "Sechseck",
+	"tool_square": "Quadrat",
+	"tool_diamond": "Diamant",
+	"tool_bubble": "Blase",
+	"tool_cloud": "Wolke",
+	"tool_burst": "Burst",
+	"tool_star": "Stern",
+	"tool_heart": "Herz",
+	"tool_scribble": "Kritzeln",
+	"tool_line": "Linie",
+	"tool_arrow": "Pfeil",
+	"search_media_placeholder": "Online-Medien suchen…",
+	"add_zone": "Neue Zone",
+	"palette": "Palette",
+	"picker": "Mischen",
+	"background_image_caption": "Bild",
+	"background_color_caption": "Farbe",
+	"upload_background_caption": "Klicke hier, um ein Hintergrundbild hochzuladen.",
+	"upload_background": "Hintergrund Hochladen",
+	"access_caption": "Zugriff",
+	"versions_caption": "Versionen",
+	"info_caption": "Info",
+	"mode_private": "Privat: Nur Mitglieder können zugreifen",
+	"mode_public": "Öffentlich: Jede(r) mit Kenntnis des Links darf reinschauen",
+	"invite_collaborators": "Mitarbeiter Einladen",
+	"invitee_email_address": "Email-Adresse des neuen Mitglieds",
+	"optional_message": "Optionale Nachricht",
+	"revoke_access": "Zugriff Entfernen",
+	"invite": "Einladen",
+	"role_viewer": "Betrachter",
+	"role_editor": "Bearbeiter",
+	"role_admin": "Admin",
+	"new_space_title": "Neuer Titel für Space",
+	"logging_in": "logging_in",
+	"password_confirmation": "Passwort Wiederholung",
+	"confirm_again": "In deinem Postfach solltest du eine Bestätigungsmail finden. Bitte klicke auf den Link darin.",
+	"confirmed": "E-Mail Adresse wurde erfolgreich bestätigt. Danke!",
+	"password_check_inbox": "password_check_inbox",
+	"viewer": "Zuschauer",
+	"editor": "Bearbeiter",
+	"admin": "Admin",
+	"mobile": "Mobil",
+	"image": "Bild",
+	"tool_filter": "Filter",
+	"team": "Team",
+	"search": "Suche",
+	"search_no_results": "Keine Resultate",
+	"search_clear": "Zurücksetzen",
+	"rename": "Umbennen",
+	"login_google": "Mit Google anmelden",
+	"save_changes": "Änderungen speichern",
+	"what_is_your_name": "Willkommen bei %s ! Bitte wähle einen Benutzernamen.",
+	"landing_title": "Dein Online-Whiteboard.",
+	"landing_claim": "Mit Spacedeck kannst du multimedial auf virtuellen Whiteboards im Internet zusammenarbeiten: Kombiniere Texte, Fotos, Websites oder sogar Videos und Sounds. ",
+	"landing_example": "Spacedeck ist ideal, um Ideen zu visualisieren, in kreativen Teams Projekte zu überblicken oder um den Unterricht in Schulen und Universitäten interaktiv zu gestalten.",
+	"spaces": "Meine Spaces",
+	"access_editor_link": "Sofort-Mitmachen-Link",
+	"access_editor_link_desc": "Mit diesem Link kann man sogar ohne Spacedeck-Account sofort mitarbeiten. Praktisch!",
+	"access_anonymous_edit_blocking": "Anonyme Mitarbeiter dürfen keine Daten anderer anonymer Mitarbeiter ändern.",
+	"access_current_members": "Aktuelle Mitarbeiter",
+	"access_new_members": "Neue Mitarbeiter einladen",
+	"landing_customers": "Tausende Anwender weltweit vertrauen uns.",
+	"landing_features_title": "Schneller zum Ergebnis.",
+	"landing_features_text": "Spacedeck 5 hat eine brandneue Benutzeroberfläche, die das Arbeiten einfacher und intuitiver und macht - gleichzeitig aber auch mehr mächtige Werkzeuge bereitstellt.",
+	"landing_features_1": "<b>Drag & Drop:</b> Bilder-, Video- und Ton-Dateien direkt vom Desktop oder von anderen Webseiten in Spaces ziehen",
+	"landing_features_2": "<b>Textnotizen</b> mit allen Möglichkeiten bei Schriftart, Farbe und Stil",
+	"landing_features_3": "<b>Zeichne und Markiere</b> freihändig oder mit fertigen Formen",
+	"landing_features_4": "Verwandle dein Whiteboard in eine <b>zoombare Präsentation</b>",
+	"landing_features_5": "<b>Arbeite in Echtzeit</b> mit deinen Kollegen, Schülern oder Freunden zusammen",
+	"landing_features_6": "<b>Teile deine Whiteboards</b> per Link oder per E-Mail",
+	"landing_features_7": "<b>Exportiere deine Arbeit</b> als PDF- oder ZIP-Datei",
+	"landing_pricing": "Unfassbar günstig.",
+	"landing_pricing_lite": "Private Nutzung",
+	"landing_pricing_lite_text": "Basisvariante, ausreichend um multimedial zu arbeiten.",
+	"landing_pricing_pro_features_list": "<ul><li>Unbegrenzte Spaces</li><li>Hierarchische Ordnerstruktur</li><li>PDF und ZIP Export</li><li>Keine Wasserzeichen</li><li>Eigene Hintergründe</li><li>Liste von Aktivitäten</li><li>20 GB Speicherplatz</li><ul>",
+	"landing_pricing_pro": "€4,90/Anwender/Mo. <br><small>oder €49,90/Anwender/Jahr</small>",
+	"landing_pricing_pro_text": "Alle Features um professionell zu arbeiten.",
+	"welcome_subject": "Willkommen bei Spacedeck",
+	"welcome_body": "Danke, dass du dich bei Spacedeck angemeldet hast. <br> Wir hoffen, du wirst viel Spaß mit Spacedeck haben. <br> Vergiss nicht, dass du mit unbegrenzt vielen Kollegen und Freunden kostenlos zusammen arbeiten kannst. ",
+	"parent_folder": "Übergeordneter Ordner",
+	"access_no_members": "Noch keine Mitglieder",
+	"invite_emails": "E-Mail Adressen",
+	"created_by": "Erstellt von",
+	"last_updated": "Zuletzt aktualisiert",
+	"comments": "Kommentare",
+	"history_recently_updated": "Aktuelles",
+	"history_recently_empty": "Noch nichts passiert.",
+	"signing_up": "Registierung läuft",
+	"feedback_sent": "Danke für dein Feedback!",
+	"role_member": "Mitglied",
+	"space_message_subject": "Neue Nachricht im Space %s",
+	"space_message_body": "%s schrieb in %s: \n",
+	"password_reset_subject": "Neues Passwort für Spacedeck",
+	"password_reset_body": "Du möchtest das Passwort für deinen Spacedeck Account zurücksetzen?\nBitte klicke dafür auf den folgenden Link:",
+	"password_reset_action": "Jetzt Passwort neu setzen",
+	"pro_ad_history_headline": "Nach einem Upgrade zu Spacedeck Pro kannst du hier einen Überblick über alle aktuellen Aktivitäten in Spaces bekommen.",
+	"was_offline": "Die Verbindung wurde unterbrochen. Wir empfehlen, alle geänderten Objekte erneut zu selektieren, um sie zu speichern.",
+	"subscription_failed_user_subject": "Zahlung fehlgeschlagen",
+	"subscription_failed_user_body": "Unfortunately, we could not process your payment method. You can easly create a new payment method including PayPal in your account settings.",
+	"subscription_failed_team_subject": "Zahlung fehlgeschlagen",
+	"subscription_failed_team_body": "Unfortunately, we could not process your Payment-method for your Team-Account. Please fix your payment method asap.",
+	"add": "hinzufügen",
+	"team_name": "Team-Name",
+	"subdomain": "Subdomain",
+	"invited": "eingeladen",
+	"team_adresses": "E-Mail Adressen",
+	"duplicate_destination": "In welchen Ordner möchtest du den Space duplizieren?",
+	"duplicate_confirm": "%s nach %s duplizieren?",
+	"duplicate_success": "%s wurde in %s dupliziert.",
+	"goto_space": "Gehe zu %s",
+	"goto_folder": "Gehe zu Ordner %s",
+	"stay_here": "Hier bleiben",
+	"sharing": "sharing",
+	"list": "Liste",
+	"download_space": "Space Herunterladen",
+	"duplicate_destination_folder": "Zielordner für Duplikat",
+	"type": "Typ",
+	"promote": "Befördern",
+	"demote": "Zurückstufen",
+	"Previous Zone": "Vorherige Zone",
+	"Next Zone": "Nächste Zone"
+}
+
+window.locales.fr.translation = 
+{
+	"lang": "fr",
+	"ok": "OK",
+	"cancel": "Annuler",
+	"close": "Fermer",
+	"open": "Ouvrir",
+	"folder": "Dossier",
+	"save": "Enregistrer",
+	"saved": "Enregistrée",
+	"created": "établi",
+	"duplicate": "Dupliquer",
+	"delete": "Supprimer",
+	"remove": "Enlever",
+	"set": "Définir",
+	"reset": "Rédéfinir",
+	"thanks": "Merci",
+	"share": "Partager",
+	"signup": "S'inscrire",
+	"login": "Se connecter",
+	"logout": "Se déconnecter",
+	"email": "Adresse email",
+	"password": "Mot de passe",
+	"width": "Largeur",
+	"height": "Hauteur",
+	"nick": "Nom",
+	"role": "Rôle",
+	"members": "Membres",
+	"actions": "Actions",
+	"or": "ou",
+	"you": "vous",
+	"via": "par",
+	"by": "par",
+	"zero": "Zero",
+	"page": "Page",
+	"new": "Neuf",
+	"copy": "Copier",
+	"owner": "Possesseur",
+	"home": "Accueil",
+	"space": "Espace",
+	"second": "Seconde",
+	"not_found": "Pas trouvé.",
+	"untitled": "sans titre",
+	"untitled_space": "Espace sans titre",
+	"untitled_folder": "Dossier sans titre",
+	"sure": "Êtes-vous sûr?",
+	"specify": "Veuillez préciser:",
+	"confirm": "Veuillez confirmer",
+	"signup_google": "S'inscrire avec Google",
+	"error_unknown_email": "Combinaison inconnue de l'email et mot de passe. Ou essayer de signer avec Google.",
+	"error_password_confirmation": "Les deux mots de passe ne correspondent pas.",
+	"error_domain_blocked": "Ce domaine a été désactivé.",
+	"error_user_email_already_used": "Cette adresse email est déjà enregistré.",
+	"support": "Aide Spacedeck",
+	"offline": "Désolé , mais les serveurs Spacedeck ne peuvent pas être atteint pour le moment. Plus d' informations ici.",
+	"error": "Désolé, une erreur s'est produite. Veuillez contacter support@spacedeck.com",
+	"welcome": "Bienvenue",
+	"claim": "Le tableau blanc partagé pour tout le monde",
+	"trynow": "Essayez-le gratuitement",
+	"about": "de nous",
+	"terms": "termes",
+	"contact": "contact",
+	"privacy": "sphère privée",
+	"business_adress": "Siège social",
+	"post_adress": "Adresse courrier",
+	"phone": "téléphone",
+	"ceo": "Gestionnaire",
+	"name": "name",
+	"confirm_subject": "Confirmation de l'email Spacedeck",
+	"confirm_body": "Merci pour votre inscription à Spacedeck.\nSil vous plaît cliquez sur le lien suivant pour confirmer votre adresse e-mail.",
+	"confirm_action": "Confirmer",
+	"team_invite_membership_subject": "Team Invitation for %s",
+	"team_invite_membership_body": "You have been invited to %s on Spacedeck.\nPlease click on the following link to accept the invitation.",
+	"team_invite_user_body": "You have been invited to %s on Spacedeck.\nYour temporary password is \"%s\".\nPlease click on the following link to accept the invitation.",
+	"team_invite_admin_body": "%s was invited for your team: %s. The temporary password is \"%s\".",
+	"team_invite_membership_acction": "Accept",
+	"team_new_member_subject": "New Team Member",
+	"team_new_member_body": "%s just joined Team %s on Spacedeck.",
+	"invite_emails": "Entrer les adresses email (séparées pas des virgules)",
+	"optional_message": "Message personnel (facultatif)",
+	"space_invite_membership_subject": "Invitation Espace par %s: %s",
+	"space_invite_membership_body": "Vous avez été invité par %s à Espace \"%s\"",
+	"space_invite_membership_action": "Accepter L'invitation",
+	"folder_invite_membership_subject": "Space",
+	"folder_invite_membership_body": "You have been invited to a Team on Spacedeck. Please click on the following link to accept the invitation.",
+	"folder_invite_membership_acction": "Accept",
+	"login_google": "S'inscrire avec Google",
+	"save_changes": "Enregistrer",
+	"upgrade": "Upgrade",
+	"upgrade_now": "Mise à niveau",
+	"create_space": "Créer un espace",
+	"create_folder": "Créer un dossier",
+	"email_unconfirmed": "Email non confirmée",
+	"confirmation_sent": "L'email est envoyé.",
+	"folder_filter": "Filtre",
+	"sort_by": "Ordre",
+	"last_modified": "Dernière modification",
+	"last_opened": "Dernière ouverture",
+	"title": "Titre",
+	"edit_team": "Modifier l'équipe",
+	"edit_account": "Modifier le compte",
+	"log_out": "Déconnecter",
+	"no_spaces_yet": "Vous ne avez pas encore créé d'espaces.",
+	"new_folder_title": "Nouveau titre pour le dossier",
+	"folder_settings": "Paramètres du dossier",
+	"upload_cover_image": "Charger image de couverture",
+	"spacedeck_pro_ad_folders": "Avec Spacedeck Pro, vous pouvez organiser un nombre illimité de espaces dans les dossiers et gérer les contrôles d'accès pour chaque dossier. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_versions": "Avec Spacedeck Pro, vous pouvez enregistrer des versions illimitées de chaque espace pour suivre vos progrès ou de conserver des instantanés sécurité. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_pdf": "Avec Spacedeck Pro, vous pouvez exporter vos espaces et même des dossiers entiers belles PDF pour l'archivage, de diffusion, ou autour de l'impression. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_zip": "Avec Spacedeck Pro, vous pouvez exporter le contenu d'un espace comme un paquet ZIP. Voulez-vous en savoir plus sur les fonctionnalités Pro?",
+	"spacedeck_pro_ad_colors": "Avec Spacedeck Pro, vous pouvez mélanger vos propres couleurs en utilisant un sélecteur de couleur professionnelle.",
+	"profile_caption": "Profil",
+	"upload_avatar": "Télécharger l'image profil",
+	"uploading_avatar": "L'image de profil est téléchargée…",
+	"avatar_dimensions": "Format suggéré: 200×200 pixels.",
+	"profile_name": "Name",
+	"profile_email": "Email",
+	"send_again": "Renvoyer",
+	"confirmation_sent_long": "Lien de confirmation email envoyé. Se il vous plaît vérifier votre courrier.",
+	"confirmation_sent_another": "Nous avons envoyé un autre lien de confirmation.",
+	"confirmation_sent_dialog_text": "Nous vous avons envoyé un email expliquant comment confirmer votre adresse email.",
+	"payment_caption": "Paiement",
+	"language_caption": "Langue",
+	"notifications_caption": "Emails",
+	"notifications_option_chat": "Envoyez-moi les nouveaux commentaires par email.",
+	"notifications_option_spaces": "Envoyez-moi un résumé quotidien des modifications à mes espaces.",
+	"password_caption": "Mot de passe",
+	"current_password": "Ancien mot de passe",
+	"new_password": "Nouveau mot de passe",
+	"verify_password": "Répéter mot de passe",
+	"change_password": "Enregistrer",
+	"reset_password": "Mot de passe oublié?",
+	"terminate_caption": "Supprimer le compte",
+	"terminate_warning": "En supprimant votre compte, vos messages, espaces, dossiers et tout leur contenu seront effacés. Cette action ne peut être annulée.",
+	"terminate_warning2": "Cela ne peut pas être annulée.",
+	"terminate_reason": "Problèmes rencontrés",
+	"terminate_reason_caption": "Aidez-nous à améliorer le produit en précisant les raisons de la suppression de votre compte.",
+	"terminate_terminate": "Supprimer le compte définitivement?",
+	"space_blank1": "Ceci est votre nouvel espace.",
+	"space_blank2": "Déposez des fichiers, collez des liens web",
+	"space_blank3": "ou utilisez les outils.",
+	"space_blank4": "Soyez créatifs!",
+	"draft": "Conception",
+	"publish": "Publier",
+	"published": "Publié",
+	"save_version": "Enregistrer une version",
+	"version_saved": "Version enregistrée.",
+	"post": "Envoyer",
+	"chat_invite_cta1": "Travailler ensemble est amusant!",
+	"chat_invite_cta2": "Pourquoi ",
+	"chat_invite_cta3": "ne pas vous invitez quelques collaborateurs?",
+	"chat_invite_cta4": "",
+	"chat_message_placeholder": "Votre message ici…",
+	"view": "Vue",
+	"edit": "Éditer",
+	"present": "Prés.",
+	"chat": "Chat",
+	"meta": "Meta",
+	"tool_search": "Chercher",
+	"tool_upload": "Charger",
+	"tool_text": "Texte",
+	"tool_shape": "Dessin",
+	"tool_zones": "Zones",
+	"tool_canvas": "Mur",
+	"search_media": "Chercher le web pour les médias…",
+	"type_here": "Entrez quelque chose ici",
+	"text_formats": "Formats",
+	"format_p": "Paragraphe",
+	"format_bullets": "Liste à puces",
+	"format_numbers": "Liste numérotée",
+	"format_h1": "Titre 1",
+	"format_h2": "Titre 2",
+	"format_h3": "Titre 3",
+	"font_size": "Taille de la police",
+	"line_height": "Hauteur de ligne",
+	"tool_align": "Align",
+	"tool_styles": "Style",
+	"tool_bullets": "Puces",
+	"tool_numbers": "Numéros",
+	"color_fill": "Fond",
+	"color_stroke": "Ligne",
+	"color_text": "Text",
+	"tool_type": "Typo.",
+	"tool_box": "Box",
+	"tool_link": "Lien",
+	"tool_layout": "Layout",
+	"tool_options": "Plus",
+	"tool_stroke": "Ligne",
+	"tool_delete": "Effacer",
+	"tool_lock": "Bloquer",
+	"tool_copy": "Copie",
+	"stack": "Empiler",
+	"tool_circle": "Cercle",
+	"tool_hexagon": "Hexagone",
+	"tool_square": "Carré",
+	"tool_diamond": "Diamant",
+	"tool_bubble": "Bulle",
+	"tool_cloud": "Nuage",
+	"tool_burst": "Éclat",
+	"tool_star": "Étoile",
+	"tool_heart": "Cœur",
+	"tool_scribble": "Crayon",
+	"tool_line": "Ligne",
+	"tool_arrow": "Flèche",
+	"search_media_placeholder": "Chercher le web pour les médias…",
+	"add_zone": "Ajouter Zone",
+	"palette": "Palette",
+	"picker": "Mélange",
+	"background_image_caption": "Image",
+	"background_color_caption": "Couleur",
+	"upload_background_caption": "Cliquez ici pour télécharger une image de fond.",
+	"upload_background": "Télécharger",
+	"access_caption": "Accès",
+	"versions_caption": "Versions",
+	"info_caption": "Info",
+	"mode_private": "Privé",
+	"mode_public": "Public",
+	"invite_collaborators": "Inviter les collaborateurs",
+	"revoke_access": "Révoquer l'accès",
+	"invite": "Inviter",
+	"role_viewer": "Spectateur",
+	"role_editor": "Éditeur",
+	"role_admin": "Administrateur",
+	"new_space_title": "Nouveau titre pour l'espace",
+	"invitee_email_address": "Adresse e-mail de invitee",
+	"viewer": "Spectateur",
+	"editor": "Éditeur",
+	"admin": "Administrateur",
+	"mobile": "Mobile",
+	"image": "Image",
+	"tool_filter": "Filter",
+	"team": "Team",
+	"search": "Recherche",
+	"search_no_results": "Aucun résultat trouvé",
+	"search_clear": "Supprimer",
+	"rename": "Renommer",
+	"logging_in": "Connexion",
+	"password_confirmation": "Confirmation du mot de passe",
+	"confirm_again": "Veuillez consulter votre boîte pour confirmer votre email.",
+	"confirmed": "Adresse email confirmée avec succès. merci!",
+	"password_check_inbox": "password_check_inbox",
+	"what_is_your_name": "Bonjour! Choisir un nom d'utilisateur s'il vous plaît.",
+	"landing_title": "Le tableau blanc partagé pour tout le monde.",
+	"landing_claim": "Le tableau blanc partagé pour tout le monde.",
+	"landing_example": "Que vous soyez étudiant, enseignant ou chercheur: Avec Spacedeck il est facile pour vous de créer, de gérer et de partager des cours ou le travail en classe. Développez vos théories visuellement. Organisez des notes de recherche, web, images, audio et vidéo.",
+	"spaces": "Espaces",
+	"access_editor_link": "Lien instantané.",
+	"access_editor_link_desc": "Donnez ce lien à tous ceux que vous voulez inviter rapidement. Ils n’ont pas besoin créer un compte Spacedeck.",
+	"access_anonymous_edit_blocking": "Ces invités ne peuvent modifier que les éléments qu’ils ont eux-même créé.",
+	"access_current_members": "Membres actuels",
+	"comments": "Commentaires",
+	"access_no_members": "No members yet. You can invite some below.",
+	"access_new_members": "Inviter de nouveaux membres.",
+	"landing_customers": "Approuvé par des milliers.",
+	"landing_features_title": "Un jeu d'enfant.",
+	"landing_features_text": "Le tout nouveau Spacedeck 5 vous permet de travailler bien plus facilement grâce à sa magnifique interface simplifiée.",
+	"landing_features_1": "Glissez & déposez images, vidéos et audios de votre ordinateur ou du web",
+	"landing_features_2": "Ecrivez directement sur l'espace et choisissez les polices de caractère, couleurs et styles",
+	"landing_features_3": "Dessinez, annotez et surlignez grâce aux formes graphiques intégrées",
+	"landing_features_4": "Transformez votre espace en une présentation dynamique",
+	"landing_features_5": "Collaborez et discutez en temps réel avec vos collègues, élèves et amis",
+	"landing_features_6": "Partagez vos espaces sur le web ou par email",
+	"landing_features_7": "Exportez votre espace en PDF pour l'imprimer",
+	"landing_pricing": "Incroyablement abordable.",
+	"landing_pricing_lite": "Usage personnel",
+	"landing_pricing_lite_text": "La version de base, bien arrondi pour recueillir des images et de garder des notes.",
+	"landing_pricing_pro_features_list": "<ul><li>Unlimited Spaces</li><li>Exporter PDF, ZIP</li><li>No Watermarks</li><li>Image de fonds</li><li>Activity History</li><li>20 Go de stockage</li><ul>",
+	"landing_pricing_pro": "€4,90/User/Mo. <br><small> €49,90/User/Year</small>",
+	"landing_pricing_pro_text": "Avec toute la puissance que vous attendez.",
+	"landing_pricing_pro_features": "Avec toute la puissance que vous attendez.",
+	"welcome_subject": "Bienvenue sur Spacedeck",
+	"welcome_body": "Merci pour votre inscription à Spacedeck.\nNous espérons que vous aurez plaisir à travailler dans les Espaces. <br> Rappelez-vous que votre compte comprend un nombre illimité de collaborateurs. <br> N''hésitez pas à partager vos espaces avec des amis et collègues du monde entier.",
+	"parent_folder": "Dossier origine",
+	"created_by": "Créé par",
+	"last_updated": "Mis à jour",
+	"history_recently_updated": "Nouvelles",
+	"history_recently_empty": "Rien ne se passe",
+	"signing_up": "Signing Up",
+	"feedback_sent": "Merci pour votre commentaire!",
+	"space_message_subject": "A posté sur %s",
+	"space_message_body": "%s a commenté dans %s:\n",
+	"role_member": "role_member",
+	"password_reset_subject": "Réinitialiser le Mot de passe pour Spacedeck",
+	"password_reset_body": "Salut!<br><br>Vous avez demandé la réinitialisation de votre Mot de passe.<br>Veuillez cliquer sur le lien suivant pour définir un nouveau Mot de passe.<br>",
+	"password_reset_action": "Définir un nouveau Mot de passe",
+	"was_offline": "The connection to Spacedeck was interrupted. If you have unsaved work, please keep this browser tab open until the connection is re-established, then touch the unsaved objects again.",
+	"subscription_failed_user_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_user_body": "Unfortunately, we could not process your Payment-method. You can easly create a new payment method including PayPal in your account settings.",
+	"subscription_failed_team_subject": "Problem with your Spacedeck Payment",
+	"subscription_failed_team_body": "Unfortunately, we could not process your Payment-method for your Team-Account. Please fix your payment method asap.",
+	"pro_ad_history_headline": "Après une mise à niveau vous pouvez obtenir un aperçu de toutes les activités actuelles dans les espaces ici.",
+	"add": "ajouter",
+	"team_name": "Nom de l'équipe",
+	"subdomain": "sous-domaine",
+	"invited": "invité",
+	"team_adresses": "E-mail adresse",
+	"duplicate_destination": "Sélectionnez le dossier de destination",
+	"duplicate_confirm": "Dupliquer %s dans %s?",
+	"duplicate_success": "%s a été dupliqué dans %s.",
+	"goto_space": "Aller à l'espace %s",
+	"goto_folder": "Aller au dossier %s",
+	"stay_here": "Reste ici",
+	"download_space": "télécharger un espace",
+	"type": "Type",
+	"Previous Zone": "Zone précédent",
+	"Next Zone": "Zone suivante",
+	"list": "liste",
+	"promote": "promouvoir",
+	"demote": "rétrograder"
+}
+

+ 7157 - 0
public/javascripts/lodash.compat.js

@@ -0,0 +1,7157 @@
+/**
+ * @license
+ * Lo-Dash 2.4.1 (Custom Build) <http://lodash.com/>
+ * Build: `lodash -o ./dist/lodash.compat.js`
+ * Copyright 2012-2013 The Dojo Foundation <http://dojofoundation.org/>
+ * Based on Underscore.js 1.5.2 <http://underscorejs.org/LICENSE>
+ * Copyright 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
+ * Available under MIT license <http://lodash.com/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 internally to indicate various things */
+  var indicatorObject = {};
+
+  /** 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', 'Error', 'Function', 'Math', 'Number', 'Object',
+    'RegExp', 'String', '_', 'attachEvent', 'clearTimeout', 'isFinite', 'isNaN',
+    'parseInt', 'setTimeout'
+  ];
+
+  /** Used to fix the JScript [[DontEnum]] bug */
+  var shadowedProps = [
+    'constructor', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable',
+    'toLocaleString', 'toString', 'valueOf'
+  ];
+
+  /** 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]',
+      errorClass = '[object Error]',
+      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 as the data object for `iteratorTemplate` */
+  var iteratorData = {
+    'args': '',
+    'array': null,
+    'bottom': '',
+    'firstArg': '',
+    'init': '',
+    'keys': null,
+    'loop': '',
+    'shadowedProps': null,
+    'support': null,
+    'top': '',
+    'useHas': 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
+    };
+  }
+
+  /**
+   * Checks if `value` is a DOM node in IE < 9.
+   *
+   * @private
+   * @param {*} value The value to check.
+   * @returns {boolean} Returns `true` if the `value` is a DOM node, else `false`.
+   */
+  function isNode(value) {
+    // IE < 9 presents DOM nodes as `Object` objects except they have `toString`
+    // methods that are `typeof` "string" and still can coerce nodes to strings
+    return typeof value.toString != 'function' && typeof (value + '') == 'string';
+  }
+
+  /**
+   * 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,
+        Error = context.Error,
+        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 errorProto = Error.prototype,
+        objectProto = Object.prototype,
+        stringProto = String.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,
+        propertyIsEnumerable = objectProto.propertyIsEnumerable,
+        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;
+
+    /** Used to avoid iterating non-enumerable properties in IE < 9 */
+    var nonEnumProps = {};
+    nonEnumProps[arrayClass] = nonEnumProps[dateClass] = nonEnumProps[numberClass] = { 'constructor': true, 'toLocaleString': true, 'toString': true, 'valueOf': true };
+    nonEnumProps[boolClass] = nonEnumProps[stringClass] = { 'constructor': true, 'toString': true, 'valueOf': true };
+    nonEnumProps[errorClass] = nonEnumProps[funcClass] = nonEnumProps[regexpClass] = { 'constructor': true, 'toString': true };
+    nonEnumProps[objectClass] = { 'constructor': true };
+
+    (function() {
+      var length = shadowedProps.length;
+      while (length--) {
+        var key = shadowedProps[length];
+        for (var className in nonEnumProps) {
+          if (hasOwnProperty.call(nonEnumProps, className) && !hasOwnProperty.call(nonEnumProps[className], key)) {
+            nonEnumProps[className][key] = false;
+          }
+        }
+      }
+    }());
+
+    /*--------------------------------------------------------------------------*/
+
+    /**
+     * 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 = {};
+
+    (function() {
+      var ctor = function() { this.x = 1; },
+          object = { '0': 1, 'length': 1 },
+          props = [];
+
+      ctor.prototype = { 'valueOf': 1, 'y': 1 };
+      for (var key in new ctor) { props.push(key); }
+      for (key in arguments) { }
+
+      /**
+       * Detect if an `arguments` object's [[Class]] is resolvable (all but Firefox < 4, IE < 9).
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.argsClass = toString.call(arguments) == argsClass;
+
+      /**
+       * Detect if `arguments` objects are `Object` objects (all but Narwhal and Opera < 10.5).
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.argsObject = arguments.constructor == Object && !(arguments instanceof Array);
+
+      /**
+       * Detect if `name` or `message` properties of `Error.prototype` are
+       * enumerable by default. (IE < 9, Safari < 5.1)
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.enumErrorProps = propertyIsEnumerable.call(errorProto, 'message') || propertyIsEnumerable.call(errorProto, 'name');
+
+      /**
+       * Detect if `prototype` properties are enumerable by default.
+       *
+       * Firefox < 3.6, Opera > 9.50 - Opera < 11.60, and Safari < 5.1
+       * (if the prototype or a property on the prototype has been set)
+       * incorrectly sets a function's `prototype` property [[Enumerable]]
+       * value to `true`.
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.enumPrototypes = propertyIsEnumerable.call(ctor, 'prototype');
+
+      /**
+       * 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';
+
+      /**
+       * Detect if `arguments` object indexes are non-enumerable
+       * (Firefox < 4, IE < 9, PhantomJS, Safari < 5.1).
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.nonEnumArgs = key != 0;
+
+      /**
+       * Detect if properties shadowing those on `Object.prototype` are non-enumerable.
+       *
+       * In IE < 9 an objects own properties, shadowing non-enumerable ones, are
+       * made non-enumerable as well (a.k.a the JScript [[DontEnum]] bug).
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.nonEnumShadows = !/valueOf/.test(props);
+
+      /**
+       * Detect if own properties are iterated after inherited properties (all but IE < 9).
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.ownLast = props[0] != 'x';
+
+      /**
+       * Detect if `Array#shift` and `Array#splice` augment array-like objects correctly.
+       *
+       * Firefox < 10, IE compatibility mode, and IE < 9 have buggy Array `shift()`
+       * and `splice()` functions that fail to remove the last element, `value[0]`,
+       * of array-like objects even though the `length` property is set to `0`.
+       * The `shift()` method is buggy in IE 8 compatibility mode, while `splice()`
+       * is buggy regardless of mode in IE < 9 and buggy in compatibility mode in IE 9.
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.spliceObjects = (arrayRef.splice.call(object, 0, 1), !object[0]);
+
+      /**
+       * Detect lack of support for accessing string characters by index.
+       *
+       * IE < 8 can't access characters by index and IE 8 can only access
+       * characters by index on string literals.
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      support.unindexedChars = ('x'[0] + Object('x')[0]) != 'xx';
+
+      /**
+       * Detect if a DOM node's [[Class]] is resolvable (all but IE < 9)
+       * and that the JS engine errors when attempting to coerce an object to
+       * a string without a `toString` function.
+       *
+       * @memberOf _.support
+       * @type boolean
+       */
+      try {
+        support.nodeClass = !(toString.call(document) == objectClass && !({ 'toString': 0 } + ''));
+      } catch(e) {
+        support.nodeClass = true;
+      }
+    }(1));
+
+    /**
+     * 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 template used to create iterator functions.
+     *
+     * @private
+     * @param {Object} data The data object used to populate the text.
+     * @returns {string} Returns the interpolated text.
+     */
+    var iteratorTemplate = function(obj) {
+
+      var __p = 'var index, iterable = ' +
+      (obj.firstArg) +
+      ', result = ' +
+      (obj.init) +
+      ';\nif (!iterable) return result;\n' +
+      (obj.top) +
+      ';';
+       if (obj.array) {
+      __p += '\nvar length = iterable.length; index = -1;\nif (' +
+      (obj.array) +
+      ') {  ';
+       if (support.unindexedChars) {
+      __p += '\n  if (isString(iterable)) {\n    iterable = iterable.split(\'\')\n  }  ';
+       }
+      __p += '\n  while (++index < length) {\n    ' +
+      (obj.loop) +
+      ';\n  }\n}\nelse {  ';
+       } else if (support.nonEnumArgs) {
+      __p += '\n  var length = iterable.length; index = -1;\n  if (length && isArguments(iterable)) {\n    while (++index < length) {\n      index += \'\';\n      ' +
+      (obj.loop) +
+      ';\n    }\n  } else {  ';
+       }
+
+       if (support.enumPrototypes) {
+      __p += '\n  var skipProto = typeof iterable == \'function\';\n  ';
+       }
+
+       if (support.enumErrorProps) {
+      __p += '\n  var skipErrorProps = iterable === errorProto || iterable instanceof Error;\n  ';
+       }
+
+          var conditions = [];    if (support.enumPrototypes) { conditions.push('!(skipProto && index == "prototype")'); }    if (support.enumErrorProps)  { conditions.push('!(skipErrorProps && (index == "message" || index == "name"))'); }
+
+       if (obj.useHas && obj.keys) {
+      __p += '\n  var ownIndex = -1,\n      ownProps = objectTypes[typeof iterable] && keys(iterable),\n      length = ownProps ? ownProps.length : 0;\n\n  while (++ownIndex < length) {\n    index = ownProps[ownIndex];\n';
+          if (conditions.length) {
+      __p += '    if (' +
+      (conditions.join(' && ')) +
+      ') {\n  ';
+       }
+      __p +=
+      (obj.loop) +
+      ';    ';
+       if (conditions.length) {
+      __p += '\n    }';
+       }
+      __p += '\n  }  ';
+       } else {
+      __p += '\n  for (index in iterable) {\n';
+          if (obj.useHas) { conditions.push("hasOwnProperty.call(iterable, index)"); }    if (conditions.length) {
+      __p += '    if (' +
+      (conditions.join(' && ')) +
+      ') {\n  ';
+       }
+      __p +=
+      (obj.loop) +
+      ';    ';
+       if (conditions.length) {
+      __p += '\n    }';
+       }
+      __p += '\n  }    ';
+       if (support.nonEnumShadows) {
+      __p += '\n\n  if (iterable !== objectProto) {\n    var ctor = iterable.constructor,\n        isProto = iterable === (ctor && ctor.prototype),\n        className = iterable === stringProto ? stringClass : iterable === errorProto ? errorClass : toString.call(iterable),\n        nonEnum = nonEnumProps[className];\n      ';
+       for (k = 0; k < 7; k++) {
+      __p += '\n    index = \'' +
+      (obj.shadowedProps[k]) +
+      '\';\n    if ((!(isProto && nonEnum[index]) && hasOwnProperty.call(iterable, index))';
+              if (!obj.useHas) {
+      __p += ' || (!nonEnum[index] && iterable[index] !== objectProto[index])';
+       }
+      __p += ') {\n      ' +
+      (obj.loop) +
+      ';\n    }      ';
+       }
+      __p += '\n  }    ';
+       }
+
+       }
+
+       if (obj.array || support.nonEnumArgs) {
+      __p += '\n}';
+       }
+      __p +=
+      (obj.bottom) +
+      ';\nreturn result';
+
+      return __p
+    };
+
+    /*--------------------------------------------------------------------------*/
+
+    /**
+     * 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] || (!support.nodeClass && isNode(value))) {
+          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 ? baseEach : 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 || (!support.nodeClass && (isNode(a) || isNode(b)))) {
+          return false;
+        }
+        // in older versions of Opera, `arguments` objects have `Array` constructors
+        var ctorA = !support.argsObject && isArguments(a) ? Object : a.constructor,
+            ctorB = !support.argsObject && isArguments(b) ? Object : 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);
+
+        if (isArray(collection)) {
+          var index = -1,
+              length = collection.length;
+
+          while (++index < length) {
+            var value = collection[index];
+            setter(result, value, callback(value, index, collection), collection);
+          }
+        } else {
+          baseEach(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]);
+    }
+
+    /**
+     * Creates compiled iteration functions.
+     *
+     * @private
+     * @param {...Object} [options] The compile options object(s).
+     * @param {string} [options.array] Code to determine if the iterable is an array or array-like.
+     * @param {boolean} [options.useHas] Specify using `hasOwnProperty` checks in the object loop.
+     * @param {Function} [options.keys] A reference to `_.keys` for use in own property iteration.
+     * @param {string} [options.args] A comma separated string of iteration function arguments.
+     * @param {string} [options.top] Code to execute before the iteration branches.
+     * @param {string} [options.loop] Code to execute in the object loop.
+     * @param {string} [options.bottom] Code to execute after the iteration branches.
+     * @returns {Function} Returns the compiled function.
+     */
+    function createIterator() {
+      // data properties
+      iteratorData.shadowedProps = shadowedProps;
+
+      // iterator options
+      iteratorData.array = iteratorData.bottom = iteratorData.loop = iteratorData.top = '';
+      iteratorData.init = 'iterable';
+      iteratorData.useHas = true;
+
+      // merge options into a template data object
+      for (var object, index = 0; object = arguments[index]; index++) {
+        for (var key in object) {
+          iteratorData[key] = object[key];
+        }
+      }
+      var args = iteratorData.args;
+      iteratorData.firstArg = /^[^,]+/.exec(args)[0];
+
+      // create the function factory
+      var factory = Function(
+          'baseCreateCallback, errorClass, errorProto, hasOwnProperty, ' +
+          'indicatorObject, isArguments, isArray, isString, keys, objectProto, ' +
+          'objectTypes, nonEnumProps, stringClass, stringProto, toString',
+        'return function(' + args + ') {\n' + iteratorTemplate(iteratorData) + '\n}'
+      );
+
+      // return the compiled function
+      return factory(
+        baseCreateCallback, errorClass, errorProto, hasOwnProperty,
+        indicatorObject, isArguments, isArray, isString, iteratorData.keys, objectProto,
+        objectTypes, nonEnumProps, stringClass, stringProto, toString
+      );
+    }
+
+    /**
+     * 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)) ||
+          (!support.argsClass && isArguments(value)) ||
+          (!support.nodeClass && isNode(value))) {
+        return false;
+      }
+      // IE < 9 iterates inherited properties before own properties. If the first
+      // iterated property is an object's own property then there are no inherited
+      // enumerable properties.
+      if (support.ownLast) {
+        forIn(value, function(value, key, object) {
+          result = hasOwnProperty.call(object, key);
+          return false;
+        });
+        return result !== 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;
+    }
+    // fallback for browsers that can't detect `arguments` objects by [[Class]]
+    if (!support.argsClass) {
+      isArguments = function(value) {
+        return value && typeof value == 'object' && typeof value.length == 'number' &&
+          hasOwnProperty.call(value, 'callee') && !propertyIsEnumerable.call(value, 'callee') || 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 = createIterator({
+      'args': 'object',
+      'init': '[]',
+      'top': 'if (!(objectTypes[typeof object])) return result',
+      'loop': 'result.push(index)'
+    });
+
+    /**
+     * 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 [];
+      }
+      if ((support.enumPrototypes && typeof object == 'function') ||
+          (support.nonEnumArgs && object.length && isArguments(object))) {
+        return shimKeys(object);
+      }
+      return nativeKeys(object);
+    };
+
+    /** Reusable iterator options shared by `each`, `forIn`, and `forOwn` */
+    var eachIteratorOptions = {
+      'args': 'collection, callback, thisArg',
+      'top': "callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3)",
+      'array': "typeof length == 'number'",
+      'keys': keys,
+      'loop': 'if (callback(iterable[index], index, collection) === false) return result'
+    };
+
+    /** Reusable iterator options for `assign` and `defaults` */
+    var defaultsIteratorOptions = {
+      'args': 'object, source, guard',
+      'top':
+        'var args = arguments,\n' +
+        '    argsIndex = 0,\n' +
+        "    argsLength = typeof guard == 'number' ? 2 : args.length;\n" +
+        'while (++argsIndex < argsLength) {\n' +
+        '  iterable = args[argsIndex];\n' +
+        '  if (iterable && objectTypes[typeof iterable]) {',
+      'keys': keys,
+      'loop': "if (typeof result[index] == 'undefined') result[index] = iterable[index]",
+      'bottom': '  }\n}'
+    };
+
+    /** Reusable iterator options for `forIn` and `forOwn` */
+    var forOwnIteratorOptions = {
+      'top': 'if (!objectTypes[typeof iterable]) return result;\n' + eachIteratorOptions.top,
+      'array': false
+    };
+
+    /**
+     * 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 = {
+      '&': '&amp;',
+      '<': '&lt;',
+      '>': '&gt;',
+      '"': '&quot;',
+      "'": '&#39;'
+    };
+
+    /** 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');
+
+    /**
+     * A function compiled to iterate `arguments` objects, arrays, objects, and
+     * strings consistenly across environments, executing the callback for each
+     * element in the collection. The callback is bound to `thisArg` and invoked
+     * with three arguments; (value, index|key, collection). Callbacks may exit
+     * iteration early by explicitly returning `false`.
+     *
+     * @private
+     * @type Function
+     * @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`.
+     */
+    var baseEach = createIterator(eachIteratorOptions);
+
+    /*--------------------------------------------------------------------------*/
+
+    /**
+     * 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 = createIterator(defaultsIteratorOptions, {
+      'top':
+        defaultsIteratorOptions.top.replace(';',
+          ';\n' +
+          "if (argsLength > 3 && typeof args[argsLength - 2] == 'function') {\n" +
+          '  var callback = baseCreateCallback(args[--argsLength - 1], args[argsLength--], 2);\n' +
+          "} else if (argsLength > 2 && typeof args[argsLength - 1] == 'function') {\n" +
+          '  callback = args[--argsLength];\n' +
+          '}'
+        ),
+      'loop': 'result[index] = callback ? callback(result[index], iterable[index]) : iterable[index]'
+    });
+
+    /**
+     * 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 = createIterator(defaultsIteratorOptions);
+
+    /**
+     * 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 = createIterator(eachIteratorOptions, forOwnIteratorOptions, {
+      'useHas': false
+    });
+
+    /**
+     * 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 = createIterator(eachIteratorOptions, forOwnIteratorOptions);
+
+    /**
+     * 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 ||
+          (support.argsClass ? className == argsClass : isArguments(value))) ||
+          (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';
+    }
+    // fallback for older versions of Chrome and Safari
+    if (isFunction(/x/)) {
+      isFunction = function(value) {
+        return typeof value == 'function' && toString.call(value) == funcClass;
+      };
+    }
+
+    /**
+     * 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) || (!support.argsClass && isArguments(value))) {
+        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 && objectTypes[typeof value] && 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 ? baseEach : 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);
+
+      if (support.unindexedChars && isString(collection)) {
+        collection = collection.split('');
+      }
+      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 {
+        baseEach(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);
+
+      if (isArray(collection)) {
+        var index = -1,
+            length = collection.length;
+
+        while (++index < length) {
+          if (!(result = !!callback(collection[index], index, collection))) {
+            break;
+          }
+        }
+      } else {
+        baseEach(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);
+
+      if (isArray(collection)) {
+        var index = -1,
+            length = collection.length;
+
+        while (++index < length) {
+          var value = collection[index];
+          if (callback(value, index, collection)) {
+            result.push(value);
+          }
+        }
+      } else {
+        baseEach(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);
+
+      if (isArray(collection)) {
+        var index = -1,
+            length = collection.length;
+
+        while (++index < length) {
+          var value = collection[index];
+          if (callback(value, index, collection)) {
+            return value;
+          }
+        }
+      } else {
+        var result;
+        baseEach(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) {
+      if (callback && typeof thisArg == 'undefined' && isArray(collection)) {
+        var index = -1,
+            length = collection.length;
+
+        while (++index < length) {
+          if (callback(collection[index], index, collection) === false) {
+            break;
+          }
+        }
+      } else {
+        baseEach(collection, callback, thisArg);
+      }
+      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 iterable = collection,
+          length = collection ? collection.length : 0;
+
+      callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3);
+      if (isArray(collection)) {
+        while (length--) {
+          if (callback(collection[length], length, collection) === false) {
+            break;
+          }
+        }
+      } else {
+        if (typeof length != 'number') {
+          var props = keys(collection);
+          length = props.length;
+        } else if (support.unindexedChars && isString(collection)) {
+          iterable = collection.split('');
+        }
+        baseEach(collection, function(value, key, collection) {
+          key = props ? props[--length] : --length;
+          return callback(iterable[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,
+          result = Array(typeof length == 'number' ? length : 0);
+
+      callback = lodash.createCallback(callback, thisArg, 3);
+      if (isArray(collection)) {
+        while (++index < length) {
+          result[index] = callback(collection[index], index, collection);
+        }
+      } else {
+        baseEach(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);
+
+        baseEach(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);
+
+        baseEach(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) {
+      var noaccum = arguments.length < 3;
+      callback = lodash.createCallback(callback, thisArg, 4);
+
+      if (isArray(collection)) {
+        var index = -1,
+            length = collection.length;
+
+        if (noaccum) {
+          accumulator = collection[++index];
+        }
+        while (++index < length) {
+          accumulator = callback(accumulator, collection[index], index, collection);
+        }
+      } else {
+        baseEach(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);
+      } else if (support.unindexedChars && isString(collection)) {
+        collection = collection.split('');
+      }
+      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);
+
+      if (isArray(collection)) {
+        var index = -1,
+            length = collection.length;
+
+        while (++index < length) {
+          if ((result = callback(collection[index], index, collection))) {
+            break;
+          }
+        }
+      } else {
+        baseEach(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 (support.unindexedChars && isString(collection))
+          ? collection.split('')
+          : 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 '<p>' + func(text) + '</p>';
+     * });
+     *
+     * p('Fred, Wilma, & Pebbles');
+     * // => '<p>Fred, Wilma, &amp; Pebbles</p>'
+     */
+    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, &amp; 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('<b><%- value %></b>', { 'value': '<script>' });
+     * // => '<b>&lt;script&gt;</b>'
+     *
+     * // using the "evaluate" delimiter to generate HTML
+     * var list = '<% _.forEach(people, function(name) { %><li><%- name %></li><% }); %>';
+     * _.template(list, { 'people': ['fred', 'barney'] });
+     * // => '<li>fred</li><li>barney</li>'
+     *
+     * // using the ES6 delimiter as an alternative to the default "interpolate" delimiter
+     * _.template('hello ${ name }', { 'name': 'pebbles' });
+     * // => 'hello pebbles'
+     *
+     * // using the internal `print` function in "evaluate" delimiters
+     * _.template('<% print("hello " + name); %>!', { 'name': 'barney' });
+     * // => 'hello barney!'
+     *
+     * // using a custom template delimiters
+     * _.templateSettings = {
+     *   'interpolate': /{{([\s\S]+?)}}/g
+     * };
+     *
+     * _.template('hello {{ name }}!', { 'name': 'mustache' });
+     * // => 'hello mustache!'
+     *
+     * // using the `imports` option to import jQuery
+     * var list = '<% jq.each(people, function(name) { %><li><%- name %></li><% }); %>';
+     * _.template(list, { 'people': ['fred', 'barney'] }, { 'imports': { 'jq': jQuery } });
+     * // => '<li>fred</li><li>barney</li>'
+     *
+     * // using the `sourceURL` option to specify a custom sourceURL for the template
+     * var compiled = _.template('hello <%= name %>', null, { 'sourceURL': '/basic/greeting.jst' });
+     * compiled(data);
+     * // => find the source of "greeting.jst" under the Sources tab or Resources panel of the web inspector
+     *
+     * // using the `variable` option to ensure a with-statement isn't used in the compiled template
+     * var compiled = _.template('hi <%= data.name %>!', null, { 'variable': 'data' });
+     * compiled.source;
+     * // => function(data) {
+     *   var __t, __p = '', __e = _.escape;
+     *   __p += 'hi ' + ((__t = ( data.name )) == null ? '' : __t) + '!';
+     *   return __p;
+     * }
+     *
+     * // using the `source` property to inline compiled templates for meaningful
+     * // line numbers in error messages and a stack trace
+     * fs.writeFileSync(path.join(cwd, 'jst.js'), '\
+     *   var JST = {\
+     *     "main": ' + _.template(mainText).source + '\
+     *   };\
+     * ');
+     */
+    function template(text, data, options) {
+      // based on John Resig's `tmpl` implementation
+      // http://ejohn.org/blog/javascript-micro-templating/
+      // and Laura Doktorova's doT.js
+      // https://github.com/olado/doT
+      var settings = lodash.templateSettings;
+      text = String(text || '');
+
+      // avoid missing dependencies when `iteratorTemplate` is not defined
+      options = defaults({}, options, settings);
+
+      var imports = defaults({}, options.imports, settings.imports),
+          importsKeys = keys(imports),
+          importsValues = values(imports);
+
+      var isEvaluating,
+          index = 0,
+          interpolate = options.interpolate || reNoMatch,
+          source = "__p += '";
+
+      // compile the regexp to match each delimiter
+      var reDelimiters = RegExp(
+        (options.escape || reNoMatch).source + '|' +
+        interpolate.source + '|' +
+        (interpolate === reInterpolate ? reEsTemplate : reNoMatch).source + '|' +
+        (options.evaluate || reNoMatch).source + '|$'
+      , 'g');
+
+      text.replace(reDelimiters, function(match, escapeValue, interpolateValue, esTemplateValue, evaluateValue, offset) {
+        interpolateValue || (interpolateValue = esTemplateValue);
+
+        // escape characters that cannot be included in string literals
+        source += text.slice(index, offset).replace(reUnescapedString, escapeStringChar);
+
+        // replace delimiters with snippets
+        if (escapeValue) {
+          source += "' +\n__e(" + escapeValue + ") +\n'";
+        }
+        if (evaluateValue) {
+          isEvaluating = true;
+          source += "';\n" + evaluateValue + ";\n__p += '";
+        }
+        if (interpolateValue) {
+          source += "' +\n((__t = (" + interpolateValue + ")) == null ? '' : __t) +\n'";
+        }
+        index = offset + match.length;
+
+        // the JS engine embedded in Adobe products requires returning the `match`
+        // string in order to produce the correct `offset` value
+        return match;
+      });
+
+      source += "';\n";
+
+      // if `variable` is not specified, wrap a with-statement around the generated
+      // code to add the data object to the top of the scope chain
+      var variable = options.variable,
+          hasVariable = variable;
+
+      if (!hasVariable) {
+        variable = 'obj';
+        source = 'with (' + variable + ') {\n' + source + '\n}\n';
+      }
+      // cleanup code by stripping empty strings
+      source = (isEvaluating ? source.replace(reEmptyStringLeading, '') : source)
+        .replace(reEmptyStringMiddle, '$1')
+        .replace(reEmptyStringTrailing, '$1;');
+
+      // frame code as the function body
+      source = 'function(' + variable + ') {\n' +
+        (hasVariable ? '' : variable + ' || (' + variable + ' = {});\n') +
+        "var __t, __p = '', __e = _.escape" +
+        (isEvaluating
+          ? ', __j = Array.prototype.join;\n' +
+            "function print() { __p += __j.call(arguments, '') }\n"
+          : ';\n'
+        ) +
+        source +
+        'return __p\n}';
+
+      // Use a sourceURL for easier debugging.
+      // http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl
+      var sourceURL = '\n/*\n//# sourceURL=' + (options.sourceURL || '/lodash/template/source[' + (templateCounter++) + ']') + '\n*/';
+
+      try {
+        var result = Function(importsKeys, 'return ' + source + sourceURL).apply(undefined, importsValues);
+      } catch(e) {
+        e.source = source;
+        throw e;
+      }
+      if (data) {
+        return result(data);
+      }
+      // provide the compiled function's source by its `toString` method, in
+      // supported environments, or the `source` property as a convenience for
+      // inlining compiled templates during the build process
+      result.source = source;
+      return result;
+    }
+
+    /**
+     * Executes the callback `n` times, returning an array of the results
+     * of each callback execution. The callback is bound to `thisArg` and invoked
+     * with one argument; (index).
+     *
+     * @static
+     * @memberOf _
+     * @category Utilities
+     * @param {number} n The number of times to execute the callback.
+     * @param {Function} callback The function called per iteration.
+     * @param {*} [thisArg] The `this` binding of `callback`.
+     * @returns {Array} Returns an array of the results of each `callback` execution.
+     * @example
+     *
+     * var diceRolls = _.times(3, _.partial(_.random, 1, 6));
+     * // => [3, 6, 4]
+     *
+     * _.times(3, function(n) { mage.castSpell(n); });
+     * // => calls `mage.castSpell(n)` three times, passing `n` of `0`, `1`, and `2` respectively
+     *
+     * _.times(3, function(n) { this.cast(n); }, mage);
+     * // => also calls `mage.castSpell(n)` three times
+     */
+    function times(n, callback, thisArg) {
+      n = (n = +n) > -1 ? n : 0;
+      var index = -1,
+          result = Array(n);
+
+      callback = baseCreateCallback(callback, thisArg, 1);
+      while (++index < n) {
+        result[index] = callback(index);
+      }
+      return result;
+    }
+
+    /**
+     * The inverse of `_.escape` this method converts the HTML entities
+     * `&amp;`, `&lt;`, `&gt;`, `&quot;`, and `&#39;` in `string` to their
+     * corresponding characters.
+     *
+     * @static
+     * @memberOf _
+     * @category Utilities
+     * @param {string} string The string to unescape.
+     * @returns {string} Returns the unescaped string.
+     * @example
+     *
+     * _.unescape('Fred, Barney &amp; Pebbles');
+     * // => 'Fred, Barney & Pebbles'
+     */
+    function unescape(string) {
+      return string == null ? '' : String(string).replace(reEscapedHtml, unescapeHtmlChar);
+    }
+
+    /**
+     * Generates a unique ID. If `prefix` is provided the ID will be appended to it.
+     *
+     * @static
+     * @memberOf _
+     * @category Utilities
+     * @param {string} [prefix] The value to prefix the ID with.
+     * @returns {string} Returns the unique ID.
+     * @example
+     *
+     * _.uniqueId('contact_');
+     * // => 'contact_104'
+     *
+     * _.uniqueId();
+     * // => '105'
+     */
+    function uniqueId(prefix) {
+      var id = ++idCounter;
+      return String(prefix == null ? '' : prefix) + id;
+    }
+
+    /*--------------------------------------------------------------------------*/
+
+    /**
+     * Creates a `lodash` object that wraps the given value with explicit
+     * method chaining enabled.
+     *
+     * @static
+     * @memberOf _
+     * @category Chaining
+     * @param {*} value The value to wrap.
+     * @returns {Object} Returns the wrapper object.
+     * @example
+     *
+     * var characters = [
+     *   { 'name': 'barney',  'age': 36 },
+     *   { 'name': 'fred',    'age': 40 },
+     *   { 'name': 'pebbles', 'age': 1 }
+     * ];
+     *
+     * var youngest = _.chain(characters)
+     *     .sortBy('age')
+     *     .map(function(chr) { return chr.name + ' is ' + chr.age; })
+     *     .first()
+     *     .value();
+     * // => 'pebbles is 1'
+     */
+    function chain(value) {
+      value = new lodashWrapper(value);
+      value.__chain__ = true;
+      return value;
+    }
+
+    /**
+     * Invokes `interceptor` with the `value` as the first argument and then
+     * returns `value`. The purpose of this method is to "tap into" a method
+     * chain in order to perform operations on intermediate results within
+     * the chain.
+     *
+     * @static
+     * @memberOf _
+     * @category Chaining
+     * @param {*} value The value to provide to `interceptor`.
+     * @param {Function} interceptor The function to invoke.
+     * @returns {*} Returns `value`.
+     * @example
+     *
+     * _([1, 2, 3, 4])
+     *  .tap(function(array) { array.pop(); })
+     *  .reverse()
+     *  .value();
+     * // => [3, 2, 1]
+     */
+    function tap(value, interceptor) {
+      interceptor(value);
+      return value;
+    }
+
+    /**
+     * Enables explicit method chaining on the wrapper object.
+     *
+     * @name chain
+     * @memberOf _
+     * @category Chaining
+     * @returns {*} Returns the wrapper object.
+     * @example
+     *
+     * var characters = [
+     *   { 'name': 'barney', 'age': 36 },
+     *   { 'name': 'fred',   'age': 40 }
+     * ];
+     *
+     * // without explicit chaining
+     * _(characters).first();
+     * // => { 'name': 'barney', 'age': 36 }
+     *
+     * // with explicit chaining
+     * _(characters).chain()
+     *   .first()
+     *   .pick('age')
+     *   .value();
+     * // => { 'age': 36 }
+     */
+    function wrapperChain() {
+      this.__chain__ = true;
+      return this;
+    }
+
+    /**
+     * Produces the `toString` result of the wrapped value.
+     *
+     * @name toString
+     * @memberOf _
+     * @category Chaining
+     * @returns {string} Returns the string result.
+     * @example
+     *
+     * _([1, 2, 3]).toString();
+     * // => '1,2,3'
+     */
+    function wrapperToString() {
+      return String(this.__wrapped__);
+    }
+
+    /**
+     * Extracts the wrapped value.
+     *
+     * @name valueOf
+     * @memberOf _
+     * @alias value
+     * @category Chaining
+     * @returns {*} Returns the wrapped value.
+     * @example
+     *
+     * _([1, 2, 3]).valueOf();
+     * // => [1, 2, 3]
+     */
+    function wrapperValueOf() {
+      return this.__wrapped__;
+    }
+
+    /*--------------------------------------------------------------------------*/
+
+    // add functions that return wrapped values when chaining
+    lodash.after = after;
+    lodash.assign = assign;
+    lodash.at = at;
+    lodash.bind = bind;
+    lodash.bindAll = bindAll;
+    lodash.bindKey = bindKey;
+    lodash.chain = chain;
+    lodash.compact = compact;
+    lodash.compose = compose;
+    lodash.constant = constant;
+    lodash.countBy = countBy;
+    lodash.create = create;
+    lodash.createCallback = createCallback;
+    lodash.curry = curry;
+    lodash.debounce = debounce;
+    lodash.defaults = defaults;
+    lodash.defer = defer;
+    lodash.delay = delay;
+    lodash.difference = difference;
+    lodash.filter = filter;
+    lodash.flatten = flatten;
+    lodash.forEach = forEach;
+    lodash.forEachRight = forEachRight;
+    lodash.forIn = forIn;
+    lodash.forInRight = forInRight;
+    lodash.forOwn = forOwn;
+    lodash.forOwnRight = forOwnRight;
+    lodash.functions = functions;
+    lodash.groupBy = groupBy;
+    lodash.indexBy = indexBy;
+    lodash.initial = initial;
+    lodash.intersection = intersection;
+    lodash.invert = invert;
+    lodash.invoke = invoke;
+    lodash.keys = keys;
+    lodash.map = map;
+    lodash.mapValues = mapValues;
+    lodash.max = max;
+    lodash.memoize = memoize;
+    lodash.merge = merge;
+    lodash.min = min;
+    lodash.omit = omit;
+    lodash.once = once;
+    lodash.pairs = pairs;
+    lodash.partial = partial;
+    lodash.partialRight = partialRight;
+    lodash.pick = pick;
+    lodash.pluck = pluck;
+    lodash.property = property;
+    lodash.pull = pull;
+    lodash.range = range;
+    lodash.reject = reject;
+    lodash.remove = remove;
+    lodash.rest = rest;
+    lodash.shuffle = shuffle;
+    lodash.sortBy = sortBy;
+    lodash.tap = tap;
+    lodash.throttle = throttle;
+    lodash.times = times;
+    lodash.toArray = toArray;
+    lodash.transform = transform;
+    lodash.union = union;
+    lodash.uniq = uniq;
+    lodash.values = values;
+    lodash.where = where;
+    lodash.without = without;
+    lodash.wrap = wrap;
+    lodash.xor = xor;
+    lodash.zip = zip;
+    lodash.zipObject = zipObject;
+
+    // add aliases
+    lodash.collect = map;
+    lodash.drop = rest;
+    lodash.each = forEach;
+    lodash.eachRight = forEachRight;
+    lodash.extend = assign;
+    lodash.methods = functions;
+    lodash.object = zipObject;
+    lodash.select = filter;
+    lodash.tail = rest;
+    lodash.unique = uniq;
+    lodash.unzip = zip;
+
+    // add functions to `lodash.prototype`
+    mixin(lodash);
+
+    /*--------------------------------------------------------------------------*/
+
+    // add functions that return unwrapped values when chaining
+    lodash.clone = clone;
+    lodash.cloneDeep = cloneDeep;
+    lodash.contains = contains;
+    lodash.escape = escape;
+    lodash.every = every;
+    lodash.find = find;
+    lodash.findIndex = findIndex;
+    lodash.findKey = findKey;
+    lodash.findLast = findLast;
+    lodash.findLastIndex = findLastIndex;
+    lodash.findLastKey = findLastKey;
+    lodash.has = has;
+    lodash.identity = identity;
+    lodash.indexOf = indexOf;
+    lodash.isArguments = isArguments;
+    lodash.isArray = isArray;
+    lodash.isBoolean = isBoolean;
+    lodash.isDate = isDate;
+    lodash.isElement = isElement;
+    lodash.isEmpty = isEmpty;
+    lodash.isEqual = isEqual;
+    lodash.isFinite = isFinite;
+    lodash.isFunction = isFunction;
+    lodash.isNaN = isNaN;
+    lodash.isNull = isNull;
+    lodash.isNumber = isNumber;
+    lodash.isObject = isObject;
+    lodash.isPlainObject = isPlainObject;
+    lodash.isRegExp = isRegExp;
+    lodash.isString = isString;
+    lodash.isUndefined = isUndefined;
+    lodash.lastIndexOf = lastIndexOf;
+    lodash.mixin = mixin;
+    lodash.noConflict = noConflict;
+    lodash.noop = noop;
+    lodash.now = now;
+    lodash.parseInt = parseInt;
+    lodash.random = random;
+    lodash.reduce = reduce;
+    lodash.reduceRight = reduceRight;
+    lodash.result = result;
+    lodash.runInContext = runInContext;
+    lodash.size = size;
+    lodash.some = some;
+    lodash.sortedIndex = sortedIndex;
+    lodash.template = template;
+    lodash.unescape = unescape;
+    lodash.uniqueId = uniqueId;
+
+    // add aliases
+    lodash.all = every;
+    lodash.any = some;
+    lodash.detect = find;
+    lodash.findWhere = find;
+    lodash.foldl = reduce;
+    lodash.foldr = reduceRight;
+    lodash.include = contains;
+    lodash.inject = reduce;
+
+    mixin(function() {
+      var source = {}
+      forOwn(lodash, function(func, methodName) {
+        if (!lodash.prototype[methodName]) {
+          source[methodName] = func;
+        }
+      });
+      return source;
+    }(), false);
+
+    /*--------------------------------------------------------------------------*/
+
+    // add functions capable of returning wrapped and unwrapped values when chaining
+    lodash.first = first;
+    lodash.last = last;
+    lodash.sample = sample;
+
+    // add aliases
+    lodash.take = first;
+    lodash.head = first;
+
+    forOwn(lodash, function(func, methodName) {
+      var callbackable = methodName !== 'sample';
+      if (!lodash.prototype[methodName]) {
+        lodash.prototype[methodName]= function(n, guard) {
+          var chainAll = this.__chain__,
+              result = func(this.__wrapped__, n, guard);
+
+          return !chainAll && (n == null || (guard && !(callbackable && typeof n == 'function')))
+            ? result
+            : new lodashWrapper(result, chainAll);
+        };
+      }
+    });
+
+    /*--------------------------------------------------------------------------*/
+
+    /**
+     * The semantic version number.
+     *
+     * @static
+     * @memberOf _
+     * @type string
+     */
+    lodash.VERSION = '2.4.1';
+
+    // add "Chaining" functions to the wrapper
+    lodash.prototype.chain = wrapperChain;
+    lodash.prototype.toString = wrapperToString;
+    lodash.prototype.value = wrapperValueOf;
+    lodash.prototype.valueOf = wrapperValueOf;
+
+    // add `Array` functions that return unwrapped values
+    baseEach(['join', 'pop', 'shift'], function(methodName) {
+      var func = arrayRef[methodName];
+      lodash.prototype[methodName] = function() {
+        var chainAll = this.__chain__,
+            result = func.apply(this.__wrapped__, arguments);
+
+        return chainAll
+          ? new lodashWrapper(result, chainAll)
+          : result;
+      };
+    });
+
+    // add `Array` functions that return the existing wrapped value
+    baseEach(['push', 'reverse', 'sort', 'unshift'], function(methodName) {
+      var func = arrayRef[methodName];
+      lodash.prototype[methodName] = function() {
+        func.apply(this.__wrapped__, arguments);
+        return this;
+      };
+    });
+
+    // add `Array` functions that return new wrapped values
+    baseEach(['concat', 'slice', 'splice'], function(methodName) {
+      var func = arrayRef[methodName];
+      lodash.prototype[methodName] = function() {
+        return new lodashWrapper(func.apply(this.__wrapped__, arguments), this.__chain__);
+      };
+    });
+
+    // avoid array-like object bugs with `Array#shift` and `Array#splice`
+    // in IE < 9, Firefox < 10, Narwhal, and RingoJS
+    if (!support.spliceObjects) {
+      baseEach(['pop', 'shift', 'splice'], function(methodName) {
+        var func = arrayRef[methodName],
+            isSplice = methodName == 'splice';
+
+        lodash.prototype[methodName] = function() {
+          var chainAll = this.__chain__,
+              value = this.__wrapped__,
+              result = func.apply(value, arguments);
+
+          if (value.length === 0) {
+            delete value[0];
+          }
+          return (chainAll || isSplice)
+            ? new lodashWrapper(result, chainAll)
+            : result;
+        };
+      });
+    }
+
+    return lodash;
+  }
+
+  /*--------------------------------------------------------------------------*/
+
+  // expose Lo-Dash
+  var _ = runInContext();
+
+  // some AMD build optimizers like r.js check for condition patterns like the following:
+  if (typeof define == 'function' && typeof define.amd == 'object' && define.amd) {
+    // Expose Lo-Dash to the global object even when an AMD loader is present in
+    // case Lo-Dash is loaded with a RequireJS shim config.
+    // See http://requirejs.org/docs/api.html#config-shim
+    root._ = _;
+
+    // define as an anonymous module so, through path mapping, it can be
+    // referenced as the "underscore" module
+    define(function() {
+      return _;
+    });
+  }
+  // check for `exports` after `define` in case a build optimizer adds an `exports` object
+  else if (freeExports && freeModule) {
+    // in Node.js or RingoJS
+    if (moduleExports) {
+      (freeModule.exports = _)._ = _;
+    }
+    // in Narwhal or Rhino -require
+    else {
+      freeExports._ = _;
+    }
+  }
+  else {
+    // in a browser or Rhino
+    root._ = _;
+  }
+}.call(this));

+ 1861 - 0
public/javascripts/medium.patched.js

@@ -0,0 +1,1861 @@
+/*
+ * Medium.js
+ *
+ * Copyright 2013, Jacob Kelley - http://jakiestfu.com/
+ * Released under the MIT Licence
+ * http://opensource.org/licenses/MIT
+ *
+ * Github:  http://github.com/jakiestfu/Medium.js/
+ * Version: 1.0
+ */
+
+(function (w, d) {
+	'use strict';
+	var Medium = (function () {
+		
+		var trim = function (string) {
+				return string.replace(/^\s+|\s+$/g, '');
+			},
+			arrayContains = function (array, variable) {
+				var i = array.length;
+				while (i--) {
+					if (array[i] === variable) {
+						return true;
+					}
+				}
+				return false;
+			},
+		    //two modes, wild (native) or domesticated (rangy + undo.js)
+			rangy = w['rangy'] || null,
+			undo = w['Undo'] || null,
+			wild = (!rangy || !undo),
+			domesticated = (!wild),
+			key = w.Key = {
+				'backspace': 8,
+				'tab': 9,
+				'enter': 13,
+				'shift': 16,
+				'ctrl': 17,
+				'alt': 18,
+				'pause': 19,
+				'capsLock': 20,
+				'escape': 27,
+				'pageUp': 33,
+				'pageDown': 34,
+				'end': 35,
+				'home': 36,
+				'leftArrow': 37,
+				'upArrow': 38,
+				'rightArrow': 39,
+				'downArrow': 40,
+				'insert': 45,
+				'delete': 46,
+				'0': 48,
+				'1': 49,
+				'2': 50,
+				'3': 51,
+				'4': 52,
+				'5': 53,
+				'6': 54,
+				'7': 55,
+				'8': 56,
+				'9': 57,
+				'a': 65,
+				'b': 66,
+				'c': 67,
+				'd': 68,
+				'e': 69,
+				'f': 70,
+				'g': 71,
+				'h': 72,
+				'i': 73,
+				'j': 74,
+				'k': 75,
+				'l': 76,
+				'm': 77,
+				'n': 78,
+				'o': 79,
+				'p': 80,
+				'q': 81,
+				'r': 82,
+				's': 83,
+				't': 84,
+				'u': 85,
+				'v': 86,
+				'w': 87,
+				'x': 88,
+				'y': 89,
+				'z': 90,
+				'leftWindow': 91,
+				'rightWindowKey': 92,
+				'select': 93,
+				'numpad0': 96,
+				'numpad1': 97,
+				'numpad2': 98,
+				'numpad3': 99,
+				'numpad4': 100,
+				'numpad5': 101,
+				'numpad6': 102,
+				'numpad7': 103,
+				'numpad8': 104,
+				'numpad9': 105,
+				'multiply': 106,
+				'add': 107,
+				'subtract': 109,
+				'decimalPoint': 110,
+				'divide': 111,
+				'f1': 112,
+				'f2': 113,
+				'f3': 114,
+				'f4': 115,
+				'f5': 116,
+				'f6': 117,
+				'f7': 118,
+				'f8': 119,
+				'f9': 120,
+				'f10': 121,
+				'f11': 122,
+				'f12': 123,
+				'numLock': 144,
+				'scrollLock': 145,
+				'semiColon': 186,
+				'equalSign': 187,
+				'comma': 188,
+				'dash': 189,
+				'period': 190,
+				'forwardSlash': 191,
+				'graveAccent': 192,
+				'openBracket': 219,
+				'backSlash': 220,
+				'closeBraket': 221,
+				'singleQuote': 222
+			},
+
+			/**
+			 * Medium.js - Taking control of content editable
+			 * @constructor
+			 * @param {Object} [userSettings] user options
+			 */
+			Medium = function (userSettings) {
+				var medium = this,
+					action = new Medium.Action(),
+					cache = new Medium.Cache(),
+					cursor = new Medium.Cursor(),
+					html = new Medium.HtmlAssistant(),
+					utils = new Medium.Utilities(),
+					selection = new Medium.Selection(),
+					intercept = {
+						focus: function (e) {
+							e = e || w.event;
+							Medium.activeElement = el;
+						},
+						blur: function (e) {
+							e = e || w.event;
+							if (Medium.activeElement === el) {
+								Medium.activeElement = null;
+							}
+
+							html.placeholders();
+						},
+						down: function (e) {
+							e = e || w.event;
+
+							var keepEvent = true;
+
+							//in Chrome it sends out this event before every regular event, not sure why
+							if (e.keyCode === 229) return;
+
+							utils.isCommand(e, function () {
+								cache.cmd = true;
+							}, function () {
+								cache.cmd = false;
+							});
+
+							utils.isShift(e, function () {
+								cache.shift = true;
+							}, function () {
+								cache.shift = false;
+							});
+
+							utils.isModifier(e, function (cmd) {
+								if (cache.cmd) {
+
+									if (( (settings.mode === Medium.inlineMode) || (settings.mode === Medium.partialMode) ) && cmd !== "paste") {
+										utils.preventDefaultEvent(e);
+										return;
+									}
+
+									var cmdType = typeof cmd,
+										fn = null;
+
+									if (cmdType === "function") {
+										fn = cmd;
+									} else {
+										fn = intercept.command[cmd];
+									}
+
+									keepEvent = fn.call(medium, e);
+
+									if (keepEvent === false) {
+										utils.preventDefaultEvent(e);
+										utils.stopPropagation(e);
+									}
+								}
+							});
+
+							if (settings.maxLength !== -1) {
+								var len = html.text().length,
+									hasSelection = false,
+									selection = w.getSelection();
+
+								if (selection) {
+									hasSelection = !selection.isCollapsed;
+								}
+
+								if (len >= settings.maxLength && !utils.isSpecial(e) && !utils.isNavigational(e) && !hasSelection) {
+									return utils.preventDefaultEvent(e);
+								}
+							}
+
+							switch (e.keyCode) {
+								case key['enter']:
+									intercept.enterKey(e);
+									break;
+								case key['backspace']:
+								case key['delete']:
+									intercept.backspaceOrDeleteKey(e);
+									break;
+							}
+
+							return keepEvent;
+						},
+						up: function (e) {
+							e = e || w.event;
+							utils.isCommand(e, function () {
+								cache.cmd = false;
+							}, function () {
+								cache.cmd = true;
+							});
+							html.clean();
+							html.placeholders();
+
+							//here we have a key context, so if you need to create your own object within a specific context it is doable
+							var keyContext;
+							if (
+								settings.keyContext !== null
+								&& ( keyContext = settings.keyContext[e.keyCode] )
+							) {
+								var el = cursor.parent();
+
+								if (el) {
+									keyContext.call(medium, e, el);
+								}
+							}
+
+							action.preserveElementFocus();
+						},
+						command: {
+							bold: function (e) {
+								utils.preventDefaultEvent(e);
+								// IE uses strong instead of b
+								(new Medium.Element(medium, 'bold'))
+									.setClean(false)
+									.invoke(settings.beforeInvokeElement);
+							},
+							underline: function (e) {
+								utils.preventDefaultEvent(e);
+								(new Medium.Element(medium, 'underline'))
+									.setClean(false)
+									.invoke(settings.beforeInvokeElement);
+							},
+							italicize: function (e) {
+								utils.preventDefaultEvent(e);
+								(new Medium.Element(medium, 'italic'))
+									.setClean(false)
+									.invoke(settings.beforeInvokeElement);
+							},
+							quote: function (e) {
+							},
+							paste: function (e) {
+								medium.makeUndoable();
+								if (settings.pasteAsText) {
+									var sel = utils.selection.saveSelection();
+									utils.pasteHook(function (text) {
+										utils.selection.restoreSelection(sel);
+
+										text = text.replace(/\n/g, '<br>');
+
+										(new Medium.Html(medium, text))
+											.setClean(false)
+											.insert(settings.beforeInsertHtml, true);
+
+										html.clean();
+										html.placeholders();
+									});
+								} else {
+									html.clean();
+									html.placeholders();
+								}
+							}
+						},
+						enterKey: function (e) {
+							if (settings.mode === Medium.inlineMode) {
+								return utils.preventDefaultEvent(e);
+							}
+
+							if (!cache.shift) {
+
+								var focusedElement = html.atCaret() || {},
+									children = el.children,
+									lastChild = focusedElement === el.lastChild ? el.lastChild : null,
+									makeHR,
+									secondToLast,
+									paragraph;
+
+								if (
+									lastChild
+									&& lastChild !== el.firstChild
+									&& settings.autoHR
+									&& settings.mode !== 'partial'
+									&& settings.tags.horizontalRule
+								) {
+
+									utils.preventDefaultEvent(e);
+
+									makeHR =
+										html.text(lastChild) === ""
+										&& lastChild.nodeName.toLowerCase() === settings.tags.paragraph;
+
+									if (makeHR && children.length >= 2) {
+										secondToLast = children[children.length - 2];
+
+										if (secondToLast.nodeName.toLowerCase() === settings.tags.horizontalRule) {
+											makeHR = false;
+										}
+									}
+
+									if (makeHR) {
+										html.addTag(settings.tags.horizontalRule, false, true, focusedElement);
+										focusedElement = focusedElement.nextSibling;
+									}
+
+									if ((paragraph = html.addTag(settings.tags.paragraph, true, null, focusedElement)) !== null) {
+										paragraph.innerHTML = '';
+										cursor.set(0, paragraph);
+									}
+								}
+							}
+
+							return true;
+						},
+						backspaceOrDeleteKey: function (e) {
+							if (el.lastChild === null) return;
+
+							var lastChild = el.lastChild,
+								beforeLastChild = lastChild.previousSibling;
+
+							if (
+								lastChild
+								&& settings.tags.horizontalRule
+								&& lastChild.nodeName.toLocaleLowerCase() === settings.tags.horizontalRule
+							) {
+								el.removeChild(lastChild);
+							} else if (
+								lastChild
+								&& beforeLastChild
+								&& utils.html.text(lastChild).length < 1
+
+								&& beforeLastChild.nodeName.toLowerCase() === settings.tags.horizontalRule
+								&& lastChild.nodeName.toLowerCase() === settings.tags.paragraph
+							) {
+								el.removeChild(lastChild);
+								el.removeChild(beforeLastChild);
+							}
+						}
+					},
+					defaultSettings = {
+						element: null,
+						modifier: 'auto',
+						placeholder: "",
+						autofocus: false,
+						autoHR: true,
+						mode: Medium.richMode,
+						maxLength: -1,
+						modifiers: {
+							'b': 'bold',
+							'i': 'italicize',
+							'u': 'underline',
+							'v': 'paste'
+						},
+						tags: {
+							'break': 'br',
+							'horizontalRule': 'hr',
+							'paragraph': 'p',
+							'outerLevel': ['pre', 'blockquote', 'figure'],
+							'innerLevel': ['a', 'b', 'u', 'i', 'img', 'strong']
+						},
+						cssClasses: {
+							editor: 'Medium',
+							pasteHook: 'Medium-paste-hook',
+							placeholder: 'Medium-placeholder',
+							clear: 'Medium-clear'
+						},
+						attributes: {
+							remove: ['style', 'class']
+						},
+						pasteAsText: true,
+						beforeInvokeElement: function () {
+							//this = Medium.Element
+						},
+						beforeInsertHtml: function () {
+							//this = Medium.Html
+						},
+						beforeAddTag: function (tag, shouldFocus, isEditable, afterElement) {
+						},
+						keyContext: null,
+						pasteEventHandler: function (e) {
+							e = e || w.event;
+							medium.makeUndoable();
+							var length = medium.value().length,
+								totalLength;
+
+							if (settings.pasteAsText) {
+								utils.preventDefaultEvent(e);
+								var
+									sel = utils.selection.saveSelection(),
+									text = prompt(Medium.Messages.pastHere) || '';
+
+								if (text.length > 0) {
+									el.focus();
+									Medium.activeElement = el;
+									utils.selection.restoreSelection(sel);
+
+									//encode the text first
+									text = html.encodeHtml(text);
+
+									//cut down it's length
+									totalLength = text.length + length;
+									if (settings.maxLength > 0 && totalLength > settings.maxLength) {
+										text = text.substring(0, settings.maxLength - length);
+									}
+
+									if (settings.mode !== Medium.inlineMode) {
+										text = text.replace(/\n/g, '<br>');
+									}
+
+									(new Medium.Html(medium, text))
+										.setClean(false)
+										.insert(settings.beforeInsertHtml, true);
+
+									html.clean();
+									html.placeholders();
+
+									return false;
+								}
+							} else {
+								setTimeout(function () {
+									html.clean();
+									html.placeholders();
+								}, 20);
+							}
+						}
+					},
+					settings = utils.deepExtend(defaultSettings, userSettings),
+					el,
+					newVal,
+					i,
+					bridge = {};
+
+				for (i in defaultSettings) {
+					// Override defaults with data-attributes
+					if (
+						typeof defaultSettings[i] !== 'object'
+						&& defaultSettings.hasOwnProperty(i)
+						&& settings.element.getAttribute('data-medium-' + key)
+					) {
+						newVal = settings.element.getAttribute('data-medium-' + key);
+
+						if (newVal.toLowerCase() === "false" || newVal.toLowerCase() === "true") {
+							newVal = newVal.toLowerCase() === "true";
+						}
+						settings[i] = newVal;
+					}
+				}
+
+				if (settings.modifiers) {
+					for (i in settings.modifiers) {
+						if (typeof(key[i]) !== 'undefined') {
+							settings.modifiers[key[i]] = settings.modifiers[i];
+						}
+					}
+				}
+
+				if (settings.keyContext) {
+					for (i in settings.keyContext) {
+						if (typeof(key[i]) !== 'undefined') {
+							settings.keyContext[key[i]] = settings.keyContext[i];
+						}
+					}
+				}
+
+				// Extend Settings
+				el = settings.element;
+
+				// Editable
+				el.contentEditable = true;
+				el.className
+					+= (' ' + settings.cssClasses.editor)
+				+ (' ' + settings.cssClasses.editor + '-' + settings.mode);
+
+				settings.tags = (settings.tags || {});
+				if (settings.tags.outerLevel) {
+					settings.tags.outerLevel = settings.tags.outerLevel.concat([settings.tags.paragraph, settings.tags.horizontalRule]);
+				}
+
+				this.settings = settings;
+				this.element = el;
+				this.intercept = intercept;
+
+				this.action = action;
+				this.cache = cache;
+				this.cursor = cursor;
+				this.html = html;
+				this.utils = utils;
+				this.selection = selection;
+
+				bridge.element = el;
+				bridge.medium = this;
+				bridge.settings = settings;
+
+				bridge.action = action;
+				bridge.cache = cache;
+				bridge.cursor = cursor;
+				bridge.html = html;
+				bridge.intercept = intercept;
+				bridge.utils = utils;
+				bridge.selection = selection;
+
+				action.setBridge(bridge);
+				cache.setBridge(bridge);
+				cursor.setBridge(bridge);
+				html.setBridge(bridge);
+				utils.setBridge(bridge);
+				selection.setBridge(bridge);
+
+				// Initialize editor
+				html.clean();
+				html.placeholders();
+				action.preserveElementFocus();
+
+				// Capture Events
+				action.listen();
+
+				if (wild) {
+					this.makeUndoable = function () {
+					};
+				} else {
+					this.dirty = false;
+					this.undoable = new Medium.Undoable(this);
+					this.undo = this.undoable.undo;
+					this.redo = this.undoable.redo;
+					this.makeUndoable = this.undoable.makeUndoable;
+				}
+
+				el.medium = this;
+
+				// Set as initialized
+				cache.initialized = true;
+			};
+
+		Medium.prototype = {
+			/**
+			 *
+			 * @param {String|Object} html
+			 * @param {Function} [callback]
+			 * @returns {Medium}
+			 */
+			insertHtml: function (html, callback) {
+				var result = (new Medium.Html(this, html))
+					.insert(this.settings.beforeInsertHtml);
+
+				this.utils.triggerEvent(this.element, "change");
+
+				if (callback) {
+					callback.apply(result);
+				}
+
+				return this;
+			},
+
+			/**
+			 *
+			 * @param {String} tagName
+			 * @param {Object} [attributes]
+			 * @returns {Medium}
+			 */
+			invokeElement: function (tagName, attributes) {
+				var settings = this.settings,
+					attributes = attributes || {},
+					remove = attributes.remove || [];
+
+				switch (settings.mode) {
+					case Medium.inlineMode:
+					case Medium.partialMode:
+						return this;
+					default:
+				}
+
+				//invoke works off class, so if it isn't there, we just add it
+				if (remove.length > 0) {
+					if (!arrayContains(settings, 'class')) {
+						remove.push('class');
+					}
+				}
+
+				(new Medium.Element(this, tagName, attributes))
+					.invoke(this.settings.beforeInvokeElement);
+
+				this.utils.triggerEvent(this.element, "change");
+
+				return this;
+			},
+
+			/**
+			 * @returns {string}
+			 */
+			behavior: function () {
+				return (wild ? 'wild' : 'domesticated');
+			},
+
+			/**
+			 *
+			 * @param value
+			 * @returns {Medium}
+			 */
+			value: function (value) {
+				if (typeof value !== 'undefined') {
+					this.element.innerHTML = value;
+
+					this.html.clean();
+					this.html.placeholders();
+				} else {
+					return this.element.innerHTML;
+				}
+
+				return this;
+			},
+
+			/**
+			 * Focus on element
+			 * @returns {Medium}
+			 */
+			focus: function () {
+				var el = this.element;
+				el.focus();
+				return this;
+			},
+
+			/**
+			 * Select all text
+			 * @returns {Medium}
+			 */
+			select: function () {
+				var el = this.element,
+					range,
+					selection;
+
+				el.focus();
+
+				if (d.body.createTextRange) {
+					range = d.body.createTextRange();
+					range.moveToElementText(el);
+					range.select();
+				} else if (w.getSelection) {
+					selection = w.getSelection();
+					range = d.createRange();
+					range.selectNodeContents(el);
+					selection.removeAllRanges();
+					selection.addRange(range);
+				}
+
+				return this;
+			},
+
+			isActive: function () {
+				return (Medium.activeElement === this.element);
+			},
+
+			destroy: function () {
+				var el = this.element,
+					intercept = this.intercept,
+					settings = this.settings,
+					placeholder = this.placeholder || null;
+
+				if (placeholder !== null && placeholder.setup) {
+					//remove placeholder
+					placeholder.parentNode.removeChild(placeholder);
+					delete el.placeHolderActive;
+				}
+
+				//remove contenteditable
+				el.removeAttribute('contenteditable');
+
+				//remove classes
+				el.className = trim(el.className
+					.replace(settings.cssClasses.editor, '')
+					.replace(settings.cssClasses.clear, '')
+					.replace(settings.cssClasses.editor + '-' + settings.mode, ''));
+
+				//remove events
+				this.utils
+					.removeEvent(el, 'keyup', intercept.up)
+					.removeEvent(el, 'keydown', intercept.down)
+					.removeEvent(el, 'focus', intercept.focus)
+					.removeEvent(el, 'blur', intercept.focus)
+					.removeEvent(el, 'paste', settings.pasteEventHandler);
+			},
+
+			// Clears the element and restores the placeholder
+			clear: function () {
+				this.element.innerHTML = '';
+				this.html.placeholders();
+			}
+		};
+
+		/**
+		 * @param {Medium} medium
+		 * @param {String} tagName
+		 * @param {Object} [attributes]
+		 * @constructor
+		 */
+		Medium.Element = function (medium, tagName, attributes) {
+			this.medium = medium;
+			this.element = medium.settings.element;
+			if (wild) {
+				this.tagName = tagName;
+			} else {
+				switch (tagName.toLowerCase()) {
+					case 'bold':
+						this.tagName = 'b';
+						break;
+					case 'italic':
+						this.tagName = 'i';
+						break;
+					case 'underline':
+						this.tagName = 'u';
+						break;
+					default:
+						this.tagName = tagName;
+				}
+			}
+			this.attributes = attributes || {};
+			this.clean = true;
+		};
+
+
+		/**
+		 * @constructor
+		 * @param {Medium} medium
+		 * @param {String|HtmlElement} html
+		 */
+		Medium.Html = function (medium, html) {
+			this.medium = medium;
+			this.element = medium.settings.element;
+			this.html = html;
+			this.clean = true;
+		};
+
+		/**
+		 *
+		 * @constructor
+		 */
+		Medium.Injector = function () {
+		};
+
+		if (wild) {
+			Medium.Element.prototype = {
+				/**
+				 * @methodOf Medium.Element
+				 * @param {Function} [fn]
+				 */
+				invoke: function (fn) {
+					if (Medium.activeElement === this.element) {
+						if (fn) {
+							fn.apply(this);
+						}
+						d.execCommand(this.tagName, false);
+					}
+				},
+				setClean: function () {
+					return this;
+				}
+			};
+
+			Medium.Injector.prototype = {
+				/**
+				 * @methodOf Medium.Injector
+				 * @param {String|HtmlElement} htmlRaw
+				 * @param {Boolean} [selectInserted]
+				 * @returns {null}
+				 */
+				inject: function (htmlRaw, selectInserted) {
+					this.insertHTML(htmlRaw, selectInserted);
+					return null;
+				}
+			};
+
+			/**
+			 *
+			 * @constructor
+			 */
+			Medium.Undoable = function () {
+			};
+		}
+
+		//if medium is domesticated (ie, not wild)
+		else {
+			rangy.rangePrototype.insertNodeAtEnd = function (node) {
+				var range = this.cloneRange();
+				range.collapse(false);
+				range.insertNode(node);
+				range.detach();
+				this.setEndAfter(node);
+			};
+
+			Medium.Element.prototype = {
+				/**
+				 * @methodOf Medium.Element
+				 * @param {Function} [fn]
+				 */
+				invoke: function (fn) {
+					if (Medium.activeElement === this.element) {
+						if (fn) {
+							fn.apply(this);
+						}
+
+						var
+							attr = this.attributes,
+							tagName = this.tagName.toLowerCase(),
+							applier,
+							cl;
+
+						if (attr.className !== undefined) {
+							cl = (attr.className.split[' '] || [attr.className]).shift();
+							delete attr.className;
+						} else {
+							cl = 'medium-' + tagName;
+						}
+
+						applier = rangy.createClassApplier(cl, {
+							elementTagName: tagName,
+							elementAttributes: this.attributes
+						});
+
+						this.medium.makeUndoable();
+
+						applier.toggleSelection(w);
+
+						if (this.clean) {
+							//cleanup
+							this.medium.html.clean();
+							this.medium.html.placeholders();
+						}
+
+
+					}
+				},
+
+				/**
+				 *
+				 * @param {Boolean} clean
+				 * @returns {Medium.Element}
+				 */
+				setClean: function (clean) {
+					this.clean = clean;
+					return this;
+				}
+			};
+
+			Medium.Injector.prototype = {
+				/**
+				 * @methodOf Medium.Injector
+				 * @param {String|HtmlElement} htmlRaw
+				 * @returns {HtmlElement}
+				 */
+				inject: function (htmlRaw) {
+					var html, isConverted = false;
+					if (typeof htmlRaw === 'string') {
+						var htmlConverter = d.createElement('div');
+						htmlConverter.innerHTML = htmlRaw;
+						html = htmlConverter.childNodes;
+						isConverted = true;
+					} else {
+						html = htmlRaw;
+					}
+
+					this.insertHTML('<span id="wedge"></span>');
+
+					var wedge = d.getElementById('wedge'),
+						parent = wedge.parentNode,
+						i = 0;
+					wedge.removeAttribute('id');
+
+					if (isConverted) {
+						while (i < html.length) {
+							parent.insertBefore(html[i], wedge);
+						}
+					} else {
+						parent.insertBefore(html, wedge);
+					}
+					parent.removeChild(wedge);
+					wedge = null;
+
+					return html;
+				}
+			};
+
+			/**
+			 * @param {Medium} medium
+			 * @constructor
+			 */
+			Medium.Undoable = function (medium) {
+				var me = this,
+					element = medium.settings.element,
+					utils = medium.utils,
+					addEvent = utils.addEvent,
+					startValue = element.innerHTML,
+					timer,
+					stack = new Undo.Stack(),
+					EditCommand = Undo.Command.extend({
+						constructor: function (oldValue, newValue) {
+							this.oldValue = oldValue;
+							this.newValue = newValue;
+						},
+						execute: function () {
+						},
+						undo: function () {
+							element.innerHTML = this.oldValue;
+							medium.canUndo = stack.canUndo();
+							medium.canRedo = stack.canRedo();
+							medium.dirty = stack.dirty();
+						},
+						redo: function () {
+							element.innerHTML = this.newValue;
+							medium.canUndo = stack.canUndo();
+							medium.canRedo = stack.canRedo();
+							medium.dirty = stack.dirty();
+						}
+					}),
+					makeUndoable = function () {
+						var newValue = element.innerHTML;
+						// ignore meta key presses
+						if (newValue != startValue) {
+
+							if (!me.movingThroughStack) {
+								// this could try and make a diff instead of storing snapshots
+								stack.execute(new EditCommand(startValue, newValue));
+								startValue = newValue;
+								medium.dirty = stack.dirty();
+							}
+
+							utils.triggerEvent(medium.settings.element, "change");
+						}
+					};
+
+				this.medium = medium;
+				this.timer = timer;
+				this.stack = stack;
+				this.makeUndoable = makeUndoable;
+				this.EditCommand = EditCommand;
+				this.movingThroughStack = false;
+
+				addEvent(element, 'keyup', function (e) {
+					if (e.ctrlKey || e.keyCode === key.z) {
+						utils.preventDefaultEvent(e);
+						return;
+					}
+
+					// a way too simple algorithm in place of single-character undo
+					clearTimeout(timer);
+					timer = setTimeout(function () {
+						makeUndoable();
+					}, 250);
+				});
+
+				addEvent(element, 'keydown', function (e) {
+					if (!e.ctrlKey || e.keyCode !== key.z) {
+						me.movingThroughStack = false;
+						return true;
+					}
+
+					utils.preventDefaultEvent(e);
+
+					me.movingThroughStack = true;
+
+					if (e.shiftKey) {
+						stack.canRedo() && stack.redo()
+					} else {
+						stack.canUndo() && stack.undo();
+					}
+				});
+			};
+		}
+
+		//Thank you Tim Down (super uber genius): http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div/6691294#6691294
+		Medium.Injector.prototype.insertHTML = function (html, selectPastedContent) {
+			var sel, range;
+			if (w.getSelection) {
+				// IE9 and non-IE
+				sel = w.getSelection();
+				if (sel.getRangeAt && sel.rangeCount) {
+					range = sel.getRangeAt(0);
+					range.deleteContents();
+
+					// Range.createContextualFragment() would be useful here but is
+					// only relatively recently standardized and is not supported in
+					// some browsers (IE9, for one)
+					var el = d.createElement("div");
+					el.innerHTML = html;
+					var frag = d.createDocumentFragment(), node, lastNode;
+					while ((node = el.firstChild)) {
+						lastNode = frag.appendChild(node);
+					}
+					var firstNode = frag.firstChild;
+					range.insertNode(frag);
+
+					// Preserve the selection
+					if (lastNode) {
+						range = range.cloneRange();
+						range.setStartAfter(lastNode);
+						if (selectPastedContent) {
+							range.setStartBefore(firstNode);
+						} else {
+							range.collapse(true);
+						}
+						sel.removeAllRanges();
+						sel.addRange(range);
+					}
+				}
+			} else if ((sel = d.selection) && sel.type != "Control") {
+				// IE < 9
+				var originalRange = sel.createRange();
+				originalRange.collapse(true);
+				sel.createRange().pasteHTML(html);
+				if (selectPastedContent) {
+					range = sel.createRange();
+					range.setEndPoint("StartToStart", originalRange);
+					range.select();
+				}
+			}
+		};
+
+		Medium.Html.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			},
+			/**
+			 * @methodOf Medium.Html
+			 * @param {Function} [fn]
+			 * @param {Boolean} [selectInserted]
+			 * @returns {HtmlElement}
+			 */
+			insert: function (fn, selectInserted) {
+				if (Medium.activeElement === this.element) {
+					if (fn) {
+						fn.apply(this);
+					}
+
+					var inserted = this.injector.inject(this.html, selectInserted);
+
+					if (this.clean) {
+						//cleanup
+						this.medium.html.clean();
+						this.medium.html.placeholders();
+					}
+
+					this.medium.makeUndoable();
+
+					return inserted;
+				} else {
+					return null;
+				}
+			},
+
+			/**
+			 * @attributeOf {Medium.Injector} Medium.Html
+			 */
+			injector: new Medium.Injector(),
+
+			/**
+			 * @methodOf Medium.Html
+			 * @param clean
+			 * @returns {Medium.Html}
+			 */
+			setClean: function (clean) {
+				this.clean = clean;
+				return this;
+			}
+		};
+
+		Medium.Utilities = function () {
+		};
+		Medium.Utilities.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			},
+			/*
+			 * Keyboard Interface events
+			 */
+			isCommand: function (e, fnTrue, fnFalse) {
+				var s = this.settings;
+				if ((s.modifier === 'ctrl' && e.ctrlKey ) ||
+					(s.modifier === 'cmd' && e.metaKey ) ||
+					(s.modifier === 'auto' && (e.ctrlKey || e.metaKey) )
+				) {
+					return fnTrue.call();
+				} else {
+					return fnFalse.call();
+				}
+			},
+			isShift: function (e, fnTrue, fnFalse) {
+				if (e.shiftKey) {
+					return fnTrue.call();
+				} else {
+					return fnFalse.call();
+				}
+			},
+			isModifier: function (e, fn) {
+				var cmd = this.settings.modifiers[e.keyCode];
+				if (cmd) {
+					return fn.call(null, cmd);
+				}
+				return false;
+			},
+			special: (function () {
+				var special = {};
+
+				special[key['backspace']] = true;
+				special[key['shift']] = true;
+				special[key['ctrl']] = true;
+				special[key['alt']] = true;
+				special[key['delete']] = true;
+				special[key['cmd']] = true;
+
+				return special;
+			})(),
+			isSpecial: function (e) {
+
+				if (this.cache.cmd) {
+					return true;
+				}
+
+				return typeof this.special[e.keyCode] !== 'undefined';
+			},
+			navigational: (function () {
+				var navigational = {};
+
+				navigational[key['upArrow']] = true;
+				navigational[key['downArrow']] = true;
+				navigational[key['leftArrow']] = true;
+				navigational[key['rightArrow']] = true;
+
+				return navigational;
+			})(),
+			isNavigational: function (e) {
+				return typeof this.navigational[e.keyCode] !== 'undefined';
+			},
+
+			/*
+			 * Handle Events
+			 */
+			addEvent: function addEvent(element, eventName, func) {
+				if (element.addEventListener) {
+					element.addEventListener(eventName, func, false);
+				} else if (element.attachEvent) {
+					element.attachEvent("on" + eventName, func);
+				} else {
+					element['on' + eventName] = func;
+				}
+
+				return this;
+			},
+			removeEvent: function removeEvent(element, eventName, func) {
+				if (element.removeEventListener) {
+					element.removeEventListener(eventName, func, false);
+				} else if (element.detachEvent) {
+					element.detachEvent("on" + eventName, func);
+				} else {
+					element['on' + eventName] = null;
+				}
+
+				return this;
+			},
+			preventDefaultEvent: function (e) {
+				if (e.preventDefault) {
+					e.preventDefault();
+				} else {
+					e.returnValue = false;
+				}
+
+				return this;
+			},
+			stopPropagation: function (e) {
+				e = e || window.event;
+				e.cancelBubble = true;
+
+				if (e.stopPropagation !== undefined) {
+					e.stopPropagation();
+				}
+			},
+			triggerEvent: function (element, eventName) {
+				var e;
+				if (d.createEvent) {
+					e = d.createEvent("HTMLEvents");
+					e.initEvent(eventName, true, true);
+					e.eventName = eventName;
+					element.dispatchEvent(e);
+				} else {
+					e = d.createEventObject();
+					element.fireEvent("on" + eventName, e);
+				}
+
+				return this;
+			},
+
+			deepExtend: function (destination, source) {
+				for (var property in source) {
+					if (
+						source[property]
+						&& source[property].constructor
+						&& source[property].constructor === Object
+					) {
+						destination[property] = destination[property] || {};
+						this.deepExtend(destination[property], source[property]);
+					} else {
+						destination[property] = source[property];
+					}
+				}
+				return destination;
+			},
+			/*
+			 * This is a Paste Hook. When the user pastes
+			 * content, this ultimately converts it into
+			 * plain text before inserting the data.
+			 */
+			pasteHook: function (fn) {
+				var textarea = d.createElement('textarea'),
+					el = this.element,
+					existingValue,
+					existingLength,
+					overallLength,
+					s = this.settings,
+					medium = this.medium,
+					html = this.html;
+
+				textarea.className = s.cssClasses.pasteHook;
+
+				el.parentNode.appendChild(textarea);
+
+				textarea.focus();
+
+				if (!wild) {
+					medium.makeUndoable();
+				}
+				setTimeout(function () {
+					el.focus();
+					if (s.maxLength > 0) {
+						existingValue = html.text(el);
+						existingLength = existingValue.length;
+						overallLength = existingLength + textarea.value.length;
+						if (overallLength > existingLength) {
+							textarea.value = textarea.value.substring(0, s.maxLength - existingLength);
+						}
+					}
+					fn(textarea.value);
+					html.deleteNode(textarea);
+				}, 2);
+			},
+			setupContents: function () {
+				var el = this.element,
+					children = el.children,
+					childNodes = el.childNodes,
+					initialParagraph;
+
+				if (
+					!this.settings.tags.paragraph
+					|| children.length > 0
+					|| this.settings.mode === Medium.inlineMode
+				) {
+					return;
+				}
+
+				//has content, but no children
+				if (childNodes.length > 0) {
+					initialParagraph = d.createElement(this.settings.tags.paragraph);
+					if (el.innerHTML.match('^[&]nbsp[;]')) {
+						el.innerHTML = el.innerHTML.substring(6, el.innerHTML.length - 1);
+					}
+					initialParagraph.innerHTML = el.innerHTML;
+					el.innerHTML = '';
+					el.appendChild(initialParagraph);
+					this.cursor.set(initialParagraph.innerHTML.length, initialParagraph);
+				} else {
+					initialParagraph = d.createElement(this.settings.tags.paragraph);
+					initialParagraph.innerHTML = '&nbsp;';
+					el.appendChild(initialParagraph);
+				}
+			},
+			traverseAll: function (element, options, depth) {
+				var children = element.childNodes,
+					length = children.length,
+					i = 0,
+					node,
+					depth = depth || 1;
+
+				options = options || {};
+
+				if (length > 0) {
+					for (; i < length; i++) {
+						node = children[i];
+						switch (node.nodeType) {
+							case 1:
+								this.traverseAll(node, options, depth + 1);
+								if (options.element !== undefined) options.element(node, i, depth, element);
+								break;
+							case 3:
+								if (options.fragment !== undefined) options.fragment(node, i, depth, element);
+						}
+
+						//length may change
+						length = children.length;
+						//if length did change, and we are at the last item, this causes infinite recursion, so if we are at the last item, then stop to prevent this
+						if (node === element.lastChild) {
+							i = length;
+						}
+					}
+				}
+
+			}
+		};
+
+		/*
+		 * Handle Selection Logic
+		 */
+		Medium.Selection = function () {
+		};
+		Medium.Selection.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			},
+			saveSelection: function () {
+				if (w.getSelection) {
+					var sel = w.getSelection();
+					if (sel.rangeCount > 0) {
+						return sel.getRangeAt(0);
+					}
+				} else if (d.selection && d.selection.createRange) { // IE
+					return d.selection.createRange();
+				}
+				return null;
+			},
+
+			restoreSelection: function (range) {
+				if (range) {
+					if (w.getSelection) {
+						var sel = w.getSelection();
+						sel.removeAllRanges();
+						sel.addRange(range);
+					} else if (d.selection && range.select) { // IE
+						range.select();
+					}
+				}
+			}
+		};
+
+		/*
+		 * Handle Cursor Logic
+		 */
+		Medium.Cursor = function () {
+		};
+		Medium.Cursor.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			},
+			set: function (pos, el) {
+				var range,
+					html = this.html;
+
+				if (d.createRange) {
+					var selection = w.getSelection(),
+						lastChild = html.lastChild(),
+						length = html.text(lastChild).length - 1,
+						toModify = el ? el : lastChild,
+						theLength = ((typeof pos !== 'undefined') && (pos !== null) ? pos : length);
+
+					range = d.createRange();
+					try{
+  					range.setStart(toModify, theLength);
+					}
+					catch(e){};
+					range.collapse(true);
+					selection.removeAllRanges();
+					selection.addRange(range);
+				} else {
+					range = d.body.createTextRange();
+					range.moveToElementText(el);
+					range.collapse(false);
+					range.select();
+				}
+			},
+			parent: function () {
+				var target = null, range;
+
+				if (w.getSelection) {
+					range = w.getSelection().getRangeAt(0);
+					target = range.commonAncestorContainer;
+
+					target = (target.nodeType === 1
+						? target
+						: target.parentNode
+					);
+				}
+
+				else if (d.selection) {
+					target = d.selection.createRange().parentElement();
+				}
+
+				if (target.tagName == 'SPAN') {
+					target = target.parentNode;
+				}
+
+				return target;
+			},
+			caretToBeginning: function (el) {
+				this.set(0, el);
+			},
+			caretToEnd: function (el) {
+				this.set(this.html.text(el).length, el);
+			}
+		};
+
+		/*
+		 * HTML Abstractions
+		 */
+		Medium.HtmlAssistant = function () {
+		};
+		Medium.HtmlAssistant.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			},
+			encodeHtml: function (html) {
+				return d.createElement('a').appendChild(
+					d.createTextNode(html)).parentNode.innerHTML;
+			},
+			text: function (node, val) {
+				node = node || this.settings.element;
+				if (val) {
+					if ((node.textContent) && (typeof (node.textContent) != "undefined")) {
+						node.textContent = val;
+					} else {
+						node.innerText = val;
+					}
+				}
+
+				else if (node.innerText) {
+					return trim(node.innerText);
+				}
+
+				else if (node.textContent) {
+					return trim(node.textContent);
+				}
+				//document fragment
+				else if (node.data) {
+					return trim(node.data);
+				}
+
+				//for good measure
+				return '';
+			},
+			changeTag: function (oldNode, newTag) {
+				var newNode = d.createElement(newTag),
+					node,
+					nextNode;
+
+				node = oldNode.firstChild;
+				while (node) {
+					nextNode = node.nextSibling;
+					newNode.appendChild(node);
+					node = nextNode;
+				}
+
+				oldNode.parentNode.insertBefore(newNode, oldNode);
+				oldNode.parentNode.removeChild(oldNode);
+
+				return newNode;
+			},
+			deleteNode: function (el) {
+				el.parentNode.removeChild(el);
+			},
+			placeholders: function () {
+				//in IE8, just gracefully degrade to no placeholders
+				if (!w.getComputedStyle) return;
+
+				var that = this,
+					s = this.settings,
+					placeholder = this.medium.placeholder || (this.medium.placeholder = d.createElement('div')),
+					el = s.element,
+					style = placeholder.style,
+					elStyle = w.getComputedStyle(el, null),
+					qStyle = function (prop) {
+						return elStyle.getPropertyValue(prop)
+					},
+					utils = this.utils,
+					text = utils.html.text(el),
+					cursor = this.cursor,
+					childCount = el.children.length;
+
+				el.placeholder = placeholder;
+
+				// Empty Editor
+				if (text.length < 1 && childCount < 2) {
+					if (el.placeHolderActive) return;
+
+					if (!el.innerHTML.match('<' + s.tags.paragraph)) {
+						el.innerHTML = '';
+					}
+
+					// We need to add placeholders
+					if (s.placeholder.length > 0) {
+						if (!placeholder.setup) {
+							placeholder.setup = true;
+
+							//background & background color
+							style.background = qStyle('background');
+							style.backgroundColor = qStyle('background-color');
+
+							//text size & text color
+							style.fontSize = qStyle('font-size');
+							style.color = elStyle.color;
+
+							//begin box-model
+							//margin
+							style.marginTop = qStyle('margin-top');
+							style.marginBottom = qStyle('margin-bottom');
+							style.marginLeft = qStyle('margin-left');
+							style.marginRight = qStyle('margin-right');
+
+							//padding
+							style.paddingTop = qStyle('padding-top');
+							style.paddingBottom = qStyle('padding-bottom');
+							style.paddingLeft = qStyle('padding-left');
+							style.paddingRight = qStyle('padding-right');
+
+							//border
+							style.borderTopWidth = qStyle('border-top-width');
+							style.borderTopColor = qStyle('border-top-color');
+							style.borderTopStyle = qStyle('border-top-style');
+							style.borderBottomWidth = qStyle('border-bottom-width');
+							style.borderBottomColor = qStyle('border-bottom-color');
+							style.borderBottomStyle = qStyle('border-bottom-style');
+							style.borderLeftWidth = qStyle('border-left-width');
+							style.borderLeftColor = qStyle('border-left-color');
+							style.borderLeftStyle = qStyle('border-left-style');
+							style.borderRightWidth = qStyle('border-right-width');
+							style.borderRightColor = qStyle('border-right-color');
+							style.borderRightStyle = qStyle('border-right-style');
+							//end box model
+
+							//element setup
+							placeholder.className = s.cssClasses.placeholder + ' ' + s.cssClasses.placeholder + '-' + s.mode;
+							placeholder.innerHTML = '<div>' + s.placeholder + '</div>';
+							el.parentNode.insertBefore(placeholder, el);
+						}
+
+						el.className += ' ' + s.cssClasses.clear;
+
+						style.display = '';
+						// Add base P tag and do auto focus, give it a min height if el has one
+						style.minHeight = el.clientHeight + 'px';
+						style.minWidth = el.clientWidth + 'px';
+
+						if (s.mode !== Medium.inlineMode) {
+							utils.setupContents();
+
+							if (childCount === 0 && el.firstChild) {
+								cursor.set(0, el.firstChild);
+							}
+						}
+					}
+					el.placeHolderActive = true;
+				} else if (el.placeHolderActive) {
+					el.placeHolderActive = false;
+					style.display = 'none';
+					el.className = trim(el.className.replace(s.cssClasses.clear, ''));
+					utils.setupContents();
+				}
+			},
+
+			/**
+			 * Cleans element
+			 * @param {HtmlElement} [el] default is settings.element
+			 */
+			clean: function (el) {
+
+				/*
+				 * Deletes invalid nodes
+				 * Removes Attributes
+				 */
+				var s = this.settings,
+					placeholderClass = s.cssClasses.placeholder,
+					attributesToRemove = (s.attributes || {}).remove || [],
+					tags = s.tags || {},
+					onlyOuter = tags.outerLevel || null,
+					onlyInner = tags.innerLevel || null,
+					outerSwitch = {},
+					innerSwitch = {},
+					paragraphTag = (tags.paragraph || '').toUpperCase(),
+					html = this.html,
+					attr,
+					text,
+					j;
+
+				el = el || s.element;
+
+				if (onlyOuter !== null) {
+					for (j = 0; j < onlyOuter.length; j++) {
+						outerSwitch[onlyOuter[j].toUpperCase()] = true;
+					}
+				}
+
+				if (onlyInner !== null) {
+					for (j = 0; j < onlyInner.length; j++) {
+						innerSwitch[onlyInner[j].toUpperCase()] = true;
+					}
+				}
+
+				this.utils.traverseAll(el, {
+					element: function (child, i, depth, parent) {
+						var nodeName = child.nodeName,
+							shouldDelete = true;
+
+						// Remove attributes
+						for (j = 0; j < attributesToRemove.length; j++) {
+							attr = attributesToRemove[j];
+							if (child.hasAttribute(attr)) {
+								if (child.getAttribute(attr) !== placeholderClass) {
+									child.removeAttribute(attr);
+								}
+							}
+						}
+
+						if (onlyOuter === null && onlyInner === null) {
+							return;
+						}
+
+						if (depth === 1 && outerSwitch[nodeName] !== undefined) {
+							shouldDelete = false;
+						} else if (depth > 1 && innerSwitch[nodeName] !== undefined) {
+							shouldDelete = false;
+						}
+
+						// Convert tags or delete
+						if (shouldDelete) {
+							if (w.getComputedStyle(child, null).getPropertyValue('display') === 'block') {
+								if (paragraphTag.length > 0 && paragraphTag !== nodeName) {
+									html.changeTag(child, paragraphTag);
+								}
+
+								if (depth > 1) {
+									while (parent.childNodes.length > i) {
+										parent.parentNode.insertBefore(parent.lastChild, parent.nextSibling);
+									}
+								}
+							} else {
+								switch (nodeName) {
+									case 'BR':
+										if (child === child.parentNode.lastChild) {
+											if (child === child.parentNode.firstChild) {
+												break;
+											}
+											text = document.createTextNode("");
+											text.innerHTML = '&nbsp';
+											parent.insertBefore(text, child);
+											break;
+										}
+									default:
+										while (child.firstChild !== null) {
+											parent.insertBefore(child.firstChild, child);
+										}
+										html.deleteNode(child);
+										break;
+								}
+							}
+						}
+					}
+				});
+			},
+			lastChild: function () {
+				return this.element.lastChild;
+			},
+			addTag: function (tag, shouldFocus, isEditable, afterElement) {
+				if (!this.settings.beforeAddTag(tag, shouldFocus, isEditable, afterElement)) {
+					var newEl = d.createElement(tag),
+						toFocus;
+
+					if (typeof isEditable !== "undefined" && isEditable === false) {
+						newEl.contentEditable = false;
+					}
+					if (newEl.innerHTML.length == 0) {
+						newEl.innerHTML = ' ';
+					}
+					if (afterElement && afterElement.nextSibling) {
+						afterElement.parentNode.insertBefore(newEl, afterElement.nextSibling);
+						toFocus = afterElement.nextSibling;
+
+					} else {
+						this.settings.element.appendChild(newEl);
+						toFocus = this.html.lastChild();
+					}
+
+					if (shouldFocus) {
+						this.cache.focusedElement = toFocus;
+						this.cursor.set(0, toFocus);
+					}
+					return newEl;
+				}
+				return null;
+			},
+			baseAtCaret: function () {
+				if (!this.medium.isActive()) return null;
+
+				var sel = w.getSelection ? w.getSelection() : document.selection;
+
+				if (sel.rangeCount) {
+					var selRange = sel.getRangeAt(0),
+						container = selRange.endContainer;
+
+					switch (container.nodeType) {
+						case 3:
+							if (container.data && container.data.length != selRange.endOffset) return false;
+							break;
+					}
+
+					return container;
+				}
+
+				return null;
+			},
+			atCaret: function () {
+				var container = this.baseAtCaret() || {},
+					el = this.element;
+
+				if (container === false) return null;
+
+				while (container && container.parentNode !== el) {
+					container = container.parentNode;
+				}
+
+				if (container && container.nodeType == 1) {
+					return container;
+				}
+
+				return null;
+			}
+		};
+
+		Medium.Action = function () {
+		};
+		Medium.Action.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			},
+			listen: function () {
+				var el = this.element,
+					intercept = this.intercept;
+
+				this.utils
+					.addEvent(el, 'keyup', intercept.up)
+					.addEvent(el, 'keydown', intercept.down)
+					.addEvent(el, 'focus', intercept.focus)
+					.addEvent(el, 'blur', intercept.blur)
+					.addEvent(el, 'paste', this.settings.pasteEventHandler);
+			},
+			preserveElementFocus: function () {
+				// Fetch node that has focus
+				var anchorNode = w.getSelection ? w.getSelection().anchorNode : d.activeElement;
+				if (anchorNode) {
+					var cache = this.medium.cache,
+						s = this.settings,
+						cur = anchorNode.parentNode,
+						children = s.element.children,
+						diff = cur !== cache.focusedElement,
+						elementIndex = 0,
+						i;
+
+					// anchorNode is our target if element is empty
+					if (cur === s.element) {
+						cur = anchorNode;
+					}
+
+					// Find our child index
+					for (i = 0; i < children.length; i++) {
+						if (cur === children[i]) {
+							elementIndex = i;
+							break;
+						}
+					}
+
+					// Focused element is different
+					if (diff) {
+						cache.focusedElement = cur;
+						cache.focusedElementIndex = elementIndex;
+					}
+				}
+			}
+		};
+
+		Medium.Cache = function () {
+			this.initialized = false;
+			this.cmd = false;
+			this.focusedElement = null
+		};
+		Medium.Cache.prototype = {
+			setBridge: function (bridge) {
+				for (var i in bridge) {
+					this[i] = bridge[i];
+				}
+			}
+		};
+
+		//Modes;
+		Medium.inlineMode = 'inline';
+		Medium.partialMode = 'partial';
+		Medium.richMode = 'rich';
+		Medium.Messages = {
+			pastHere: 'Paste Here'
+		};
+
+		return Medium;
+	}());
+
+	if (typeof define === 'function' && define['amd']) {
+		define(function () { return Medium; });
+	} else if (typeof module !== 'undefined' && module.exports) {
+		module.exports = Medium;
+	} else if (typeof this !== 'undefined') {
+		this.Medium = Medium;
+	}
+
+}).call(this, window, document);

+ 2610 - 0
public/javascripts/moment.js

@@ -0,0 +1,2610 @@
+//! moment.js
+//! version : 2.7.0
+//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
+//! license : MIT
+//! momentjs.com
+
+(function (undefined) {
+
+    /************************************
+        Constants
+    ************************************/
+
+    var moment,
+        VERSION = "2.7.0",
+        // the global-scope this is NOT the global object in Node.js
+        globalScope = typeof global !== 'undefined' ? global : this,
+        oldGlobalMoment,
+        round = Math.round,
+        i,
+
+        YEAR = 0,
+        MONTH = 1,
+        DATE = 2,
+        HOUR = 3,
+        MINUTE = 4,
+        SECOND = 5,
+        MILLISECOND = 6,
+
+        // internal storage for language config files
+        languages = {},
+
+        // moment internal properties
+        momentProperties = {
+            _isAMomentObject: null,
+            _i : null,
+            _f : null,
+            _l : null,
+            _strict : null,
+            _tzm : null,
+            _isUTC : null,
+            _offset : null,  // optional. Combine with _isUTC
+            _pf : null,
+            _lang : null  // optional
+        },
+
+        // check for nodeJS
+        hasModule = (typeof module !== 'undefined' && module.exports),
+
+        // ASP.NET json date format regex
+        aspNetJsonRegex = /^\/?Date\((\-?\d+)/i,
+        aspNetTimeSpanJsonRegex = /(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/,
+
+        // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html
+        // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere
+        isoDurationRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/,
+
+        // format tokens
+        formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|X|zz?|ZZ?|.)/g,
+        localFormattingTokens = /(\[[^\[]*\])|(\\)?(LT|LL?L?L?|l{1,4})/g,
+
+        // parsing token regexes
+        parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99
+        parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999
+        parseTokenOneToFourDigits = /\d{1,4}/, // 0 - 9999
+        parseTokenOneToSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999
+        parseTokenDigits = /\d+/, // nonzero number of digits
+        parseTokenWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i, // any word (or two) characters or numbers including two/three word month in arabic.
+        parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z
+        parseTokenT = /T/i, // T (ISO separator)
+        parseTokenTimestampMs = /[\+\-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123
+        parseTokenOrdinal = /\d{1,2}/,
+
+        //strict parsing regexes
+        parseTokenOneDigit = /\d/, // 0 - 9
+        parseTokenTwoDigits = /\d\d/, // 00 - 99
+        parseTokenThreeDigits = /\d{3}/, // 000 - 999
+        parseTokenFourDigits = /\d{4}/, // 0000 - 9999
+        parseTokenSixDigits = /[+-]?\d{6}/, // -999,999 - 999,999
+        parseTokenSignedNumber = /[+-]?\d+/, // -inf - inf
+
+        // iso 8601 regex
+        // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00)
+        isoRegex = /^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,
+
+        isoFormat = 'YYYY-MM-DDTHH:mm:ssZ',
+
+        isoDates = [
+            ['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/],
+            ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/],
+            ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/],
+            ['GGGG-[W]WW', /\d{4}-W\d{2}/],
+            ['YYYY-DDD', /\d{4}-\d{3}/]
+        ],
+
+        // iso time formats and regexes
+        isoTimes = [
+            ['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d+/],
+            ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/],
+            ['HH:mm', /(T| )\d\d:\d\d/],
+            ['HH', /(T| )\d\d/]
+        ],
+
+        // timezone chunker "+10:00" > ["10", "00"] or "-1530" > ["-15", "30"]
+        parseTimezoneChunker = /([\+\-]|\d\d)/gi,
+
+        // getter and setter names
+        proxyGettersAndSetters = 'Date|Hours|Minutes|Seconds|Milliseconds'.split('|'),
+        unitMillisecondFactors = {
+            'Milliseconds' : 1,
+            'Seconds' : 1e3,
+            'Minutes' : 6e4,
+            'Hours' : 36e5,
+            'Days' : 864e5,
+            'Months' : 2592e6,
+            'Years' : 31536e6
+        },
+
+        unitAliases = {
+            ms : 'millisecond',
+            s : 'second',
+            m : 'minute',
+            h : 'hour',
+            d : 'day',
+            D : 'date',
+            w : 'week',
+            W : 'isoWeek',
+            M : 'month',
+            Q : 'quarter',
+            y : 'year',
+            DDD : 'dayOfYear',
+            e : 'weekday',
+            E : 'isoWeekday',
+            gg: 'weekYear',
+            GG: 'isoWeekYear'
+        },
+
+        camelFunctions = {
+            dayofyear : 'dayOfYear',
+            isoweekday : 'isoWeekday',
+            isoweek : 'isoWeek',
+            weekyear : 'weekYear',
+            isoweekyear : 'isoWeekYear'
+        },
+
+        // format function strings
+        formatFunctions = {},
+
+        // default relative time thresholds
+        relativeTimeThresholds = {
+          s: 45,   //seconds to minutes
+          m: 45,   //minutes to hours
+          h: 22,   //hours to days
+          dd: 25,  //days to month (month == 1)
+          dm: 45,  //days to months (months > 1)
+          dy: 345  //days to year
+        },
+
+        // tokens to ordinalize and pad
+        ordinalizeTokens = 'DDD w W M D d'.split(' '),
+        paddedTokens = 'M D H h m s w W'.split(' '),
+
+        formatTokenFunctions = {
+            M    : function () {
+                return this.month() + 1;
+            },
+            MMM  : function (format) {
+                return this.lang().monthsShort(this, format);
+            },
+            MMMM : function (format) {
+                return this.lang().months(this, format);
+            },
+            D    : function () {
+                return this.date();
+            },
+            DDD  : function () {
+                return this.dayOfYear();
+            },
+            d    : function () {
+                return this.day();
+            },
+            dd   : function (format) {
+                return this.lang().weekdaysMin(this, format);
+            },
+            ddd  : function (format) {
+                return this.lang().weekdaysShort(this, format);
+            },
+            dddd : function (format) {
+                return this.lang().weekdays(this, format);
+            },
+            w    : function () {
+                return this.week();
+            },
+            W    : function () {
+                return this.isoWeek();
+            },
+            YY   : function () {
+                return leftZeroFill(this.year() % 100, 2);
+            },
+            YYYY : function () {
+                return leftZeroFill(this.year(), 4);
+            },
+            YYYYY : function () {
+                return leftZeroFill(this.year(), 5);
+            },
+            YYYYYY : function () {
+                var y = this.year(), sign = y >= 0 ? '+' : '-';
+                return sign + leftZeroFill(Math.abs(y), 6);
+            },
+            gg   : function () {
+                return leftZeroFill(this.weekYear() % 100, 2);
+            },
+            gggg : function () {
+                return leftZeroFill(this.weekYear(), 4);
+            },
+            ggggg : function () {
+                return leftZeroFill(this.weekYear(), 5);
+            },
+            GG   : function () {
+                return leftZeroFill(this.isoWeekYear() % 100, 2);
+            },
+            GGGG : function () {
+                return leftZeroFill(this.isoWeekYear(), 4);
+            },
+            GGGGG : function () {
+                return leftZeroFill(this.isoWeekYear(), 5);
+            },
+            e : function () {
+                return this.weekday();
+            },
+            E : function () {
+                return this.isoWeekday();
+            },
+            a    : function () {
+                return this.lang().meridiem(this.hours(), this.minutes(), true);
+            },
+            A    : function () {
+                return this.lang().meridiem(this.hours(), this.minutes(), false);
+            },
+            H    : function () {
+                return this.hours();
+            },
+            h    : function () {
+                return this.hours() % 12 || 12;
+            },
+            m    : function () {
+                return this.minutes();
+            },
+            s    : function () {
+                return this.seconds();
+            },
+            S    : function () {
+                return toInt(this.milliseconds() / 100);
+            },
+            SS   : function () {
+                return leftZeroFill(toInt(this.milliseconds() / 10), 2);
+            },
+            SSS  : function () {
+                return leftZeroFill(this.milliseconds(), 3);
+            },
+            SSSS : function () {
+                return leftZeroFill(this.milliseconds(), 3);
+            },
+            Z    : function () {
+                var a = -this.zone(),
+                    b = "+";
+                if (a < 0) {
+                    a = -a;
+                    b = "-";
+                }
+                return b + leftZeroFill(toInt(a / 60), 2) + ":" + leftZeroFill(toInt(a) % 60, 2);
+            },
+            ZZ   : function () {
+                var a = -this.zone(),
+                    b = "+";
+                if (a < 0) {
+                    a = -a;
+                    b = "-";
+                }
+                return b + leftZeroFill(toInt(a / 60), 2) + leftZeroFill(toInt(a) % 60, 2);
+            },
+            z : function () {
+                return this.zoneAbbr();
+            },
+            zz : function () {
+                return this.zoneName();
+            },
+            X    : function () {
+                return this.unix();
+            },
+            Q : function () {
+                return this.quarter();
+            }
+        },
+
+        lists = ['months', 'monthsShort', 'weekdays', 'weekdaysShort', 'weekdaysMin'];
+
+    // Pick the first defined of two or three arguments. dfl comes from
+    // default.
+    function dfl(a, b, c) {
+        switch (arguments.length) {
+            case 2: return a != null ? a : b;
+            case 3: return a != null ? a : b != null ? b : c;
+            default: throw new Error("Implement me");
+        }
+    }
+
+    function defaultParsingFlags() {
+        // We need to deep clone this object, and es5 standard is not very
+        // helpful.
+        return {
+            empty : false,
+            unusedTokens : [],
+            unusedInput : [],
+            overflow : -2,
+            charsLeftOver : 0,
+            nullInput : false,
+            invalidMonth : null,
+            invalidFormat : false,
+            userInvalidated : false,
+            iso: false
+        };
+    }
+
+    function deprecate(msg, fn) {
+        var firstTime = true;
+        function printMsg() {
+            if (moment.suppressDeprecationWarnings === false &&
+                    typeof console !== 'undefined' && console.warn) {
+                console.warn("Deprecation warning: " + msg);
+            }
+        }
+        return extend(function () {
+            if (firstTime) {
+                printMsg();
+                firstTime = false;
+            }
+            return fn.apply(this, arguments);
+        }, fn);
+    }
+
+    function padToken(func, count) {
+        return function (a) {
+            return leftZeroFill(func.call(this, a), count);
+        };
+    }
+    function ordinalizeToken(func, period) {
+        return function (a) {
+            return this.lang().ordinal(func.call(this, a), period);
+        };
+    }
+
+    while (ordinalizeTokens.length) {
+        i = ordinalizeTokens.pop();
+        formatTokenFunctions[i + 'o'] = ordinalizeToken(formatTokenFunctions[i], i);
+    }
+    while (paddedTokens.length) {
+        i = paddedTokens.pop();
+        formatTokenFunctions[i + i] = padToken(formatTokenFunctions[i], 2);
+    }
+    formatTokenFunctions.DDDD = padToken(formatTokenFunctions.DDD, 3);
+
+
+    /************************************
+        Constructors
+    ************************************/
+
+    function Language() {
+
+    }
+
+    // Moment prototype object
+    function Moment(config) {
+        checkOverflow(config);
+        extend(this, config);
+    }
+
+    // Duration Constructor
+    function Duration(duration) {
+        var normalizedInput = normalizeObjectUnits(duration),
+            years = normalizedInput.year || 0,
+            quarters = normalizedInput.quarter || 0,
+            months = normalizedInput.month || 0,
+            weeks = normalizedInput.week || 0,
+            days = normalizedInput.day || 0,
+            hours = normalizedInput.hour || 0,
+            minutes = normalizedInput.minute || 0,
+            seconds = normalizedInput.second || 0,
+            milliseconds = normalizedInput.millisecond || 0;
+
+        // representation for dateAddRemove
+        this._milliseconds = +milliseconds +
+            seconds * 1e3 + // 1000
+            minutes * 6e4 + // 1000 * 60
+            hours * 36e5; // 1000 * 60 * 60
+        // Because of dateAddRemove treats 24 hours as different from a
+        // day when working around DST, we need to store them separately
+        this._days = +days +
+            weeks * 7;
+        // It is impossible translate months into days without knowing
+        // which months you are are talking about, so we have to store
+        // it separately.
+        this._months = +months +
+            quarters * 3 +
+            years * 12;
+
+        this._data = {};
+
+        this._bubble();
+    }
+
+    /************************************
+        Helpers
+    ************************************/
+
+
+    function extend(a, b) {
+        for (var i in b) {
+            if (b.hasOwnProperty(i)) {
+                a[i] = b[i];
+            }
+        }
+
+        if (b.hasOwnProperty("toString")) {
+            a.toString = b.toString;
+        }
+
+        if (b.hasOwnProperty("valueOf")) {
+            a.valueOf = b.valueOf;
+        }
+
+        return a;
+    }
+
+    function cloneMoment(m) {
+        var result = {}, i;
+        for (i in m) {
+            if (m.hasOwnProperty(i) && momentProperties.hasOwnProperty(i)) {
+                result[i] = m[i];
+            }
+        }
+
+        return result;
+    }
+
+    function absRound(number) {
+        if (number < 0) {
+            return Math.ceil(number);
+        } else {
+            return Math.floor(number);
+        }
+    }
+
+    // left zero fill a number
+    // see http://jsperf.com/left-zero-filling for performance comparison
+    function leftZeroFill(number, targetLength, forceSign) {
+        var output = '' + Math.abs(number),
+            sign = number >= 0;
+
+        while (output.length < targetLength) {
+            output = '0' + output;
+        }
+        return (sign ? (forceSign ? '+' : '') : '-') + output;
+    }
+
+    // helper function for _.addTime and _.subtractTime
+    function addOrSubtractDurationFromMoment(mom, duration, isAdding, updateOffset) {
+        var milliseconds = duration._milliseconds,
+            days = duration._days,
+            months = duration._months;
+        updateOffset = updateOffset == null ? true : updateOffset;
+
+        if (milliseconds) {
+            mom._d.setTime(+mom._d + milliseconds * isAdding);
+        }
+        if (days) {
+            rawSetter(mom, 'Date', rawGetter(mom, 'Date') + days * isAdding);
+        }
+        if (months) {
+            rawMonthSetter(mom, rawGetter(mom, 'Month') + months * isAdding);
+        }
+        if (updateOffset) {
+            moment.updateOffset(mom, days || months);
+        }
+    }
+
+    // check if is an array
+    function isArray(input) {
+        return Object.prototype.toString.call(input) === '[object Array]';
+    }
+
+    function isDate(input) {
+        return  Object.prototype.toString.call(input) === '[object Date]' ||
+                input instanceof Date;
+    }
+
+    // compare two arrays, return the number of differences
+    function compareArrays(array1, array2, dontConvert) {
+        var len = Math.min(array1.length, array2.length),
+            lengthDiff = Math.abs(array1.length - array2.length),
+            diffs = 0,
+            i;
+        for (i = 0; i < len; i++) {
+            if ((dontConvert && array1[i] !== array2[i]) ||
+                (!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) {
+                diffs++;
+            }
+        }
+        return diffs + lengthDiff;
+    }
+
+    function normalizeUnits(units) {
+        if (units) {
+            var lowered = units.toLowerCase().replace(/(.)s$/, '$1');
+            units = unitAliases[units] || camelFunctions[lowered] || lowered;
+        }
+        return units;
+    }
+
+    function normalizeObjectUnits(inputObject) {
+        var normalizedInput = {},
+            normalizedProp,
+            prop;
+
+        for (prop in inputObject) {
+            if (inputObject.hasOwnProperty(prop)) {
+                normalizedProp = normalizeUnits(prop);
+                if (normalizedProp) {
+                    normalizedInput[normalizedProp] = inputObject[prop];
+                }
+            }
+        }
+
+        return normalizedInput;
+    }
+
+    function makeList(field) {
+        var count, setter;
+
+        if (field.indexOf('week') === 0) {
+            count = 7;
+            setter = 'day';
+        }
+        else if (field.indexOf('month') === 0) {
+            count = 12;
+            setter = 'month';
+        }
+        else {
+            return;
+        }
+
+        moment[field] = function (format, index) {
+            var i, getter,
+                method = moment.fn._lang[field],
+                results = [];
+
+            if (typeof format === 'number') {
+                index = format;
+                format = undefined;
+            }
+
+            getter = function (i) {
+                var m = moment().utc().set(setter, i);
+                return method.call(moment.fn._lang, m, format || '');
+            };
+
+            if (index != null) {
+                return getter(index);
+            }
+            else {
+                for (i = 0; i < count; i++) {
+                    results.push(getter(i));
+                }
+                return results;
+            }
+        };
+    }
+
+    function toInt(argumentForCoercion) {
+        var coercedNumber = +argumentForCoercion,
+            value = 0;
+
+        if (coercedNumber !== 0 && isFinite(coercedNumber)) {
+            if (coercedNumber >= 0) {
+                value = Math.floor(coercedNumber);
+            } else {
+                value = Math.ceil(coercedNumber);
+            }
+        }
+
+        return value;
+    }
+
+    function daysInMonth(year, month) {
+        return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
+    }
+
+    function weeksInYear(year, dow, doy) {
+        return weekOfYear(moment([year, 11, 31 + dow - doy]), dow, doy).week;
+    }
+
+    function daysInYear(year) {
+        return isLeapYear(year) ? 366 : 365;
+    }
+
+    function isLeapYear(year) {
+        return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+    }
+
+    function checkOverflow(m) {
+        var overflow;
+        if (m._a && m._pf.overflow === -2) {
+            overflow =
+                m._a[MONTH] < 0 || m._a[MONTH] > 11 ? MONTH :
+                m._a[DATE] < 1 || m._a[DATE] > daysInMonth(m._a[YEAR], m._a[MONTH]) ? DATE :
+                m._a[HOUR] < 0 || m._a[HOUR] > 23 ? HOUR :
+                m._a[MINUTE] < 0 || m._a[MINUTE] > 59 ? MINUTE :
+                m._a[SECOND] < 0 || m._a[SECOND] > 59 ? SECOND :
+                m._a[MILLISECOND] < 0 || m._a[MILLISECOND] > 999 ? MILLISECOND :
+                -1;
+
+            if (m._pf._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) {
+                overflow = DATE;
+            }
+
+            m._pf.overflow = overflow;
+        }
+    }
+
+    function isValid(m) {
+        if (m._isValid == null) {
+            m._isValid = !isNaN(m._d.getTime()) &&
+                m._pf.overflow < 0 &&
+                !m._pf.empty &&
+                !m._pf.invalidMonth &&
+                !m._pf.nullInput &&
+                !m._pf.invalidFormat &&
+                !m._pf.userInvalidated;
+
+            if (m._strict) {
+                m._isValid = m._isValid &&
+                    m._pf.charsLeftOver === 0 &&
+                    m._pf.unusedTokens.length === 0;
+            }
+        }
+        return m._isValid;
+    }
+
+    function normalizeLanguage(key) {
+        return key ? key.toLowerCase().replace('_', '-') : key;
+    }
+
+    // Return a moment from input, that is local/utc/zone equivalent to model.
+    function makeAs(input, model) {
+        return model._isUTC ? moment(input).zone(model._offset || 0) :
+            moment(input).local();
+    }
+
+    /************************************
+        Languages
+    ************************************/
+
+
+    extend(Language.prototype, {
+
+        set : function (config) {
+            var prop, i;
+            for (i in config) {
+                prop = config[i];
+                if (typeof prop === 'function') {
+                    this[i] = prop;
+                } else {
+                    this['_' + i] = prop;
+                }
+            }
+        },
+
+        _months : "January_February_March_April_May_June_July_August_September_October_November_December".split("_"),
+        months : function (m) {
+            return this._months[m.month()];
+        },
+
+        _monthsShort : "Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),
+        monthsShort : function (m) {
+            return this._monthsShort[m.month()];
+        },
+
+        monthsParse : function (monthName) {
+            var i, mom, regex;
+
+            if (!this._monthsParse) {
+                this._monthsParse = [];
+            }
+
+            for (i = 0; i < 12; i++) {
+                // make the regex if we don't have it already
+                if (!this._monthsParse[i]) {
+                    mom = moment.utc([2000, i]);
+                    regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, '');
+                    this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i');
+                }
+                // test the regex
+                if (this._monthsParse[i].test(monthName)) {
+                    return i;
+                }
+            }
+        },
+
+        _weekdays : "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),
+        weekdays : function (m) {
+            return this._weekdays[m.day()];
+        },
+
+        _weekdaysShort : "Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),
+        weekdaysShort : function (m) {
+            return this._weekdaysShort[m.day()];
+        },
+
+        _weekdaysMin : "Su_Mo_Tu_We_Th_Fr_Sa".split("_"),
+        weekdaysMin : function (m) {
+            return this._weekdaysMin[m.day()];
+        },
+
+        weekdaysParse : function (weekdayName) {
+            var i, mom, regex;
+
+            if (!this._weekdaysParse) {
+                this._weekdaysParse = [];
+            }
+
+            for (i = 0; i < 7; i++) {
+                // make the regex if we don't have it already
+                if (!this._weekdaysParse[i]) {
+                    mom = moment([2000, 1]).day(i);
+                    regex = '^' + this.weekdays(mom, '') + '|^' + this.weekdaysShort(mom, '') + '|^' + this.weekdaysMin(mom, '');
+                    this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i');
+                }
+                // test the regex
+                if (this._weekdaysParse[i].test(weekdayName)) {
+                    return i;
+                }
+            }
+        },
+
+        _longDateFormat : {
+            LT : "h:mm A",
+            L : "MM/DD/YYYY",
+            LL : "MMMM D YYYY",
+            LLL : "MMMM D YYYY LT",
+            LLLL : "dddd, MMMM D YYYY LT"
+        },
+        longDateFormat : function (key) {
+            var output = this._longDateFormat[key];
+            if (!output && this._longDateFormat[key.toUpperCase()]) {
+                output = this._longDateFormat[key.toUpperCase()].replace(/MMMM|MM|DD|dddd/g, function (val) {
+                    return val.slice(1);
+                });
+                this._longDateFormat[key] = output;
+            }
+            return output;
+        },
+
+        isPM : function (input) {
+            // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays
+            // Using charAt should be more compatible.
+            return ((input + '').toLowerCase().charAt(0) === 'p');
+        },
+
+        _meridiemParse : /[ap]\.?m?\.?/i,
+        meridiem : function (hours, minutes, isLower) {
+            if (hours > 11) {
+                return isLower ? 'pm' : 'PM';
+            } else {
+                return isLower ? 'am' : 'AM';
+            }
+        },
+
+        _calendar : {
+            sameDay : '[Today at] LT',
+            nextDay : '[Tomorrow at] LT',
+            nextWeek : 'dddd [at] LT',
+            lastDay : '[Yesterday at] LT',
+            lastWeek : '[Last] dddd [at] LT',
+            sameElse : 'L'
+        },
+        calendar : function (key, mom) {
+            var output = this._calendar[key];
+            return typeof output === 'function' ? output.apply(mom) : output;
+        },
+
+        _relativeTime : {
+            future : "in %s",
+            past : "%s ago",
+            s : "a few seconds",
+            m : "a minute",
+            mm : "%d minutes",
+            h : "an hour",
+            hh : "%d hours",
+            d : "a day",
+            dd : "%d days",
+            M : "a month",
+            MM : "%d months",
+            y : "a year",
+            yy : "%d years"
+        },
+        relativeTime : function (number, withoutSuffix, string, isFuture) {
+            var output = this._relativeTime[string];
+            return (typeof output === 'function') ?
+                output(number, withoutSuffix, string, isFuture) :
+                output.replace(/%d/i, number);
+        },
+        pastFuture : function (diff, output) {
+            var format = this._relativeTime[diff > 0 ? 'future' : 'past'];
+            return typeof format === 'function' ? format(output) : format.replace(/%s/i, output);
+        },
+
+        ordinal : function (number) {
+            return this._ordinal.replace("%d", number);
+        },
+        _ordinal : "%d",
+
+        preparse : function (string) {
+            return string;
+        },
+
+        postformat : function (string) {
+            return string;
+        },
+
+        week : function (mom) {
+            return weekOfYear(mom, this._week.dow, this._week.doy).week;
+        },
+
+        _week : {
+            dow : 0, // Sunday is the first day of the week.
+            doy : 6  // The week that contains Jan 1st is the first week of the year.
+        },
+
+        _invalidDate: 'Invalid date',
+        invalidDate: function () {
+            return this._invalidDate;
+        }
+    });
+
+    // Loads a language definition into the `languages` cache.  The function
+    // takes a key and optionally values.  If not in the browser and no values
+    // are provided, it will load the language file module.  As a convenience,
+    // this function also returns the language values.
+    function loadLang(key, values) {
+        values.abbr = key;
+        if (!languages[key]) {
+            languages[key] = new Language();
+        }
+        languages[key].set(values);
+        return languages[key];
+    }
+
+    // Remove a language from the `languages` cache. Mostly useful in tests.
+    function unloadLang(key) {
+        delete languages[key];
+    }
+
+    // Determines which language definition to use and returns it.
+    //
+    // With no parameters, it will return the global language.  If you
+    // pass in a language key, such as 'en', it will return the
+    // definition for 'en', so long as 'en' has already been loaded using
+    // moment.lang.
+    function getLangDefinition(key) {
+        var i = 0, j, lang, next, split,
+            get = function (k) {
+                if (!languages[k] && hasModule) {
+                    try {
+                        require('./lang/' + k);
+                    } catch (e) { }
+                }
+                return languages[k];
+            };
+
+        if (!key) {
+            return moment.fn._lang;
+        }
+
+        if (!isArray(key)) {
+            //short-circuit everything else
+            lang = get(key);
+            if (lang) {
+                return lang;
+            }
+            key = [key];
+        }
+
+        //pick the language from the array
+        //try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each
+        //substring from most specific to least, but move to the next array item if it's a more specific variant than the current root
+        while (i < key.length) {
+            split = normalizeLanguage(key[i]).split('-');
+            j = split.length;
+            next = normalizeLanguage(key[i + 1]);
+            next = next ? next.split('-') : null;
+            while (j > 0) {
+                lang = get(split.slice(0, j).join('-'));
+                if (lang) {
+                    return lang;
+                }
+                if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) {
+                    //the next array item is better than a shallower substring of this one
+                    break;
+                }
+                j--;
+            }
+            i++;
+        }
+        return moment.fn._lang;
+    }
+
+    /************************************
+        Formatting
+    ************************************/
+
+
+    function removeFormattingTokens(input) {
+        if (input.match(/\[[\s\S]/)) {
+            return input.replace(/^\[|\]$/g, "");
+        }
+        return input.replace(/\\/g, "");
+    }
+
+    function makeFormatFunction(format) {
+        var array = format.match(formattingTokens), i, length;
+
+        for (i = 0, length = array.length; i < length; i++) {
+            if (formatTokenFunctions[array[i]]) {
+                array[i] = formatTokenFunctions[array[i]];
+            } else {
+                array[i] = removeFormattingTokens(array[i]);
+            }
+        }
+
+        return function (mom) {
+            var output = "";
+            for (i = 0; i < length; i++) {
+                output += array[i] instanceof Function ? array[i].call(mom, format) : array[i];
+            }
+            return output;
+        };
+    }
+
+    // format date using native date object
+    function formatMoment(m, format) {
+
+        if (!m.isValid()) {
+            return m.lang().invalidDate();
+        }
+
+        format = expandFormat(format, m.lang());
+
+        if (!formatFunctions[format]) {
+            formatFunctions[format] = makeFormatFunction(format);
+        }
+
+        return formatFunctions[format](m);
+    }
+
+    function expandFormat(format, lang) {
+        var i = 5;
+
+        function replaceLongDateFormatTokens(input) {
+            return lang.longDateFormat(input) || input;
+        }
+
+        localFormattingTokens.lastIndex = 0;
+        while (i >= 0 && localFormattingTokens.test(format)) {
+            format = format.replace(localFormattingTokens, replaceLongDateFormatTokens);
+            localFormattingTokens.lastIndex = 0;
+            i -= 1;
+        }
+
+        return format;
+    }
+
+
+    /************************************
+        Parsing
+    ************************************/
+
+
+    // get the regex to find the next token
+    function getParseRegexForToken(token, config) {
+        var a, strict = config._strict;
+        switch (token) {
+        case 'Q':
+            return parseTokenOneDigit;
+        case 'DDDD':
+            return parseTokenThreeDigits;
+        case 'YYYY':
+        case 'GGGG':
+        case 'gggg':
+            return strict ? parseTokenFourDigits : parseTokenOneToFourDigits;
+        case 'Y':
+        case 'G':
+        case 'g':
+            return parseTokenSignedNumber;
+        case 'YYYYYY':
+        case 'YYYYY':
+        case 'GGGGG':
+        case 'ggggg':
+            return strict ? parseTokenSixDigits : parseTokenOneToSixDigits;
+        case 'S':
+            if (strict) { return parseTokenOneDigit; }
+            /* falls through */
+        case 'SS':
+            if (strict) { return parseTokenTwoDigits; }
+            /* falls through */
+        case 'SSS':
+            if (strict) { return parseTokenThreeDigits; }
+            /* falls through */
+        case 'DDD':
+            return parseTokenOneToThreeDigits;
+        case 'MMM':
+        case 'MMMM':
+        case 'dd':
+        case 'ddd':
+        case 'dddd':
+            return parseTokenWord;
+        case 'a':
+        case 'A':
+            return getLangDefinition(config._l)._meridiemParse;
+        case 'X':
+            return parseTokenTimestampMs;
+        case 'Z':
+        case 'ZZ':
+            return parseTokenTimezone;
+        case 'T':
+            return parseTokenT;
+        case 'SSSS':
+            return parseTokenDigits;
+        case 'MM':
+        case 'DD':
+        case 'YY':
+        case 'GG':
+        case 'gg':
+        case 'HH':
+        case 'hh':
+        case 'mm':
+        case 'ss':
+        case 'ww':
+        case 'WW':
+            return strict ? parseTokenTwoDigits : parseTokenOneOrTwoDigits;
+        case 'M':
+        case 'D':
+        case 'd':
+        case 'H':
+        case 'h':
+        case 'm':
+        case 's':
+        case 'w':
+        case 'W':
+        case 'e':
+        case 'E':
+            return parseTokenOneOrTwoDigits;
+        case 'Do':
+            return parseTokenOrdinal;
+        default :
+            a = new RegExp(regexpEscape(unescapeFormat(token.replace('\\', '')), "i"));
+            return a;
+        }
+    }
+
+    function timezoneMinutesFromString(string) {
+        string = string || "";
+        var possibleTzMatches = (string.match(parseTokenTimezone) || []),
+            tzChunk = possibleTzMatches[possibleTzMatches.length - 1] || [],
+            parts = (tzChunk + '').match(parseTimezoneChunker) || ['-', 0, 0],
+            minutes = +(parts[1] * 60) + toInt(parts[2]);
+
+        return parts[0] === '+' ? -minutes : minutes;
+    }
+
+    // function to convert string input to date
+    function addTimeToArrayFromToken(token, input, config) {
+        var a, datePartArray = config._a;
+
+        switch (token) {
+        // QUARTER
+        case 'Q':
+            if (input != null) {
+                datePartArray[MONTH] = (toInt(input) - 1) * 3;
+            }
+            break;
+        // MONTH
+        case 'M' : // fall through to MM
+        case 'MM' :
+            if (input != null) {
+                datePartArray[MONTH] = toInt(input) - 1;
+            }
+            break;
+        case 'MMM' : // fall through to MMMM
+        case 'MMMM' :
+            a = getLangDefinition(config._l).monthsParse(input);
+            // if we didn't find a month name, mark the date as invalid.
+            if (a != null) {
+                datePartArray[MONTH] = a;
+            } else {
+                config._pf.invalidMonth = input;
+            }
+            break;
+        // DAY OF MONTH
+        case 'D' : // fall through to DD
+        case 'DD' :
+            if (input != null) {
+                datePartArray[DATE] = toInt(input);
+            }
+            break;
+        case 'Do' :
+            if (input != null) {
+                datePartArray[DATE] = toInt(parseInt(input, 10));
+            }
+            break;
+        // DAY OF YEAR
+        case 'DDD' : // fall through to DDDD
+        case 'DDDD' :
+            if (input != null) {
+                config._dayOfYear = toInt(input);
+            }
+
+            break;
+        // YEAR
+        case 'YY' :
+            datePartArray[YEAR] = moment.parseTwoDigitYear(input);
+            break;
+        case 'YYYY' :
+        case 'YYYYY' :
+        case 'YYYYYY' :
+            datePartArray[YEAR] = toInt(input);
+            break;
+        // AM / PM
+        case 'a' : // fall through to A
+        case 'A' :
+            config._isPm = getLangDefinition(config._l).isPM(input);
+            break;
+        // 24 HOUR
+        case 'H' : // fall through to hh
+        case 'HH' : // fall through to hh
+        case 'h' : // fall through to hh
+        case 'hh' :
+            datePartArray[HOUR] = toInt(input);
+            break;
+        // MINUTE
+        case 'm' : // fall through to mm
+        case 'mm' :
+            datePartArray[MINUTE] = toInt(input);
+            break;
+        // SECOND
+        case 's' : // fall through to ss
+        case 'ss' :
+            datePartArray[SECOND] = toInt(input);
+            break;
+        // MILLISECOND
+        case 'S' :
+        case 'SS' :
+        case 'SSS' :
+        case 'SSSS' :
+            datePartArray[MILLISECOND] = toInt(('0.' + input) * 1000);
+            break;
+        // UNIX TIMESTAMP WITH MS
+        case 'X':
+            config._d = new Date(parseFloat(input) * 1000);
+            break;
+        // TIMEZONE
+        case 'Z' : // fall through to ZZ
+        case 'ZZ' :
+            config._useUTC = true;
+            config._tzm = timezoneMinutesFromString(input);
+            break;
+        // WEEKDAY - human
+        case 'dd':
+        case 'ddd':
+        case 'dddd':
+            a = getLangDefinition(config._l).weekdaysParse(input);
+            // if we didn't get a weekday name, mark the date as invalid
+            if (a != null) {
+                config._w = config._w || {};
+                config._w['d'] = a;
+            } else {
+                config._pf.invalidWeekday = input;
+            }
+            break;
+        // WEEK, WEEK DAY - numeric
+        case 'w':
+        case 'ww':
+        case 'W':
+        case 'WW':
+        case 'd':
+        case 'e':
+        case 'E':
+            token = token.substr(0, 1);
+            /* falls through */
+        case 'gggg':
+        case 'GGGG':
+        case 'GGGGG':
+            token = token.substr(0, 2);
+            if (input) {
+                config._w = config._w || {};
+                config._w[token] = toInt(input);
+            }
+            break;
+        case 'gg':
+        case 'GG':
+            config._w = config._w || {};
+            config._w[token] = moment.parseTwoDigitYear(input);
+        }
+    }
+
+    function dayOfYearFromWeekInfo(config) {
+        var w, weekYear, week, weekday, dow, doy, temp, lang;
+
+        w = config._w;
+        if (w.GG != null || w.W != null || w.E != null) {
+            dow = 1;
+            doy = 4;
+
+            // TODO: We need to take the current isoWeekYear, but that depends on
+            // how we interpret now (local, utc, fixed offset). So create
+            // a now version of current config (take local/utc/offset flags, and
+            // create now).
+            weekYear = dfl(w.GG, config._a[YEAR], weekOfYear(moment(), 1, 4).year);
+            week = dfl(w.W, 1);
+            weekday = dfl(w.E, 1);
+        } else {
+            lang = getLangDefinition(config._l);
+            dow = lang._week.dow;
+            doy = lang._week.doy;
+
+            weekYear = dfl(w.gg, config._a[YEAR], weekOfYear(moment(), dow, doy).year);
+            week = dfl(w.w, 1);
+
+            if (w.d != null) {
+                // weekday -- low day numbers are considered next week
+                weekday = w.d;
+                if (weekday < dow) {
+                    ++week;
+                }
+            } else if (w.e != null) {
+                // local weekday -- counting starts from begining of week
+                weekday = w.e + dow;
+            } else {
+                // default to begining of week
+                weekday = dow;
+            }
+        }
+        temp = dayOfYearFromWeeks(weekYear, week, weekday, doy, dow);
+
+        config._a[YEAR] = temp.year;
+        config._dayOfYear = temp.dayOfYear;
+    }
+
+    // convert an array to a date.
+    // the array should mirror the parameters below
+    // note: all values past the year are optional and will default to the lowest possible value.
+    // [year, month, day , hour, minute, second, millisecond]
+    function dateFromConfig(config) {
+        var i, date, input = [], currentDate, yearToUse;
+
+        if (config._d) {
+            return;
+        }
+
+        currentDate = currentDateArray(config);
+
+        //compute day of the year from weeks and weekdays
+        if (config._w && config._a[DATE] == null && config._a[MONTH] == null) {
+            dayOfYearFromWeekInfo(config);
+        }
+
+        //if the day of the year is set, figure out what it is
+        if (config._dayOfYear) {
+            yearToUse = dfl(config._a[YEAR], currentDate[YEAR]);
+
+            if (config._dayOfYear > daysInYear(yearToUse)) {
+                config._pf._overflowDayOfYear = true;
+            }
+
+            date = makeUTCDate(yearToUse, 0, config._dayOfYear);
+            config._a[MONTH] = date.getUTCMonth();
+            config._a[DATE] = date.getUTCDate();
+        }
+
+        // Default to current date.
+        // * if no year, month, day of month are given, default to today
+        // * if day of month is given, default month and year
+        // * if month is given, default only year
+        // * if year is given, don't default anything
+        for (i = 0; i < 3 && config._a[i] == null; ++i) {
+            config._a[i] = input[i] = currentDate[i];
+        }
+
+        // Zero out whatever was not defaulted, including time
+        for (; i < 7; i++) {
+            config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i];
+        }
+
+        config._d = (config._useUTC ? makeUTCDate : makeDate).apply(null, input);
+        // Apply timezone offset from input. The actual zone can be changed
+        // with parseZone.
+        if (config._tzm != null) {
+            config._d.setUTCMinutes(config._d.getUTCMinutes() + config._tzm);
+        }
+    }
+
+    function dateFromObject(config) {
+        var normalizedInput;
+
+        if (config._d) {
+            return;
+        }
+
+        normalizedInput = normalizeObjectUnits(config._i);
+        config._a = [
+            normalizedInput.year,
+            normalizedInput.month,
+            normalizedInput.day,
+            normalizedInput.hour,
+            normalizedInput.minute,
+            normalizedInput.second,
+            normalizedInput.millisecond
+        ];
+
+        dateFromConfig(config);
+    }
+
+    function currentDateArray(config) {
+        var now = new Date();
+        if (config._useUTC) {
+            return [
+                now.getUTCFullYear(),
+                now.getUTCMonth(),
+                now.getUTCDate()
+            ];
+        } else {
+            return [now.getFullYear(), now.getMonth(), now.getDate()];
+        }
+    }
+
+    // date from string and format string
+    function makeDateFromStringAndFormat(config) {
+
+        if (config._f === moment.ISO_8601) {
+            parseISO(config);
+            return;
+        }
+
+        config._a = [];
+        config._pf.empty = true;
+
+        // This array is used to make a Date, either with `new Date` or `Date.UTC`
+        var lang = getLangDefinition(config._l),
+            string = '' + config._i,
+            i, parsedInput, tokens, token, skipped,
+            stringLength = string.length,
+            totalParsedInputLength = 0;
+
+        tokens = expandFormat(config._f, lang).match(formattingTokens) || [];
+
+        for (i = 0; i < tokens.length; i++) {
+            token = tokens[i];
+            parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0];
+            if (parsedInput) {
+                skipped = string.substr(0, string.indexOf(parsedInput));
+                if (skipped.length > 0) {
+                    config._pf.unusedInput.push(skipped);
+                }
+                string = string.slice(string.indexOf(parsedInput) + parsedInput.length);
+                totalParsedInputLength += parsedInput.length;
+            }
+            // don't parse if it's not a known token
+            if (formatTokenFunctions[token]) {
+                if (parsedInput) {
+                    config._pf.empty = false;
+                }
+                else {
+                    config._pf.unusedTokens.push(token);
+                }
+                addTimeToArrayFromToken(token, parsedInput, config);
+            }
+            else if (config._strict && !parsedInput) {
+                config._pf.unusedTokens.push(token);
+            }
+        }
+
+        // add remaining unparsed input length to the string
+        config._pf.charsLeftOver = stringLength - totalParsedInputLength;
+        if (string.length > 0) {
+            config._pf.unusedInput.push(string);
+        }
+
+        // handle am pm
+        if (config._isPm && config._a[HOUR] < 12) {
+            config._a[HOUR] += 12;
+        }
+        // if is 12 am, change hours to 0
+        if (config._isPm === false && config._a[HOUR] === 12) {
+            config._a[HOUR] = 0;
+        }
+
+        dateFromConfig(config);
+        checkOverflow(config);
+    }
+
+    function unescapeFormat(s) {
+        return s.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) {
+            return p1 || p2 || p3 || p4;
+        });
+    }
+
+    // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript
+    function regexpEscape(s) {
+        return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+    }
+
+    // date from string and array of format strings
+    function makeDateFromStringAndArray(config) {
+        var tempConfig,
+            bestMoment,
+
+            scoreToBeat,
+            i,
+            currentScore;
+
+        if (config._f.length === 0) {
+            config._pf.invalidFormat = true;
+            config._d = new Date(NaN);
+            return;
+        }
+
+        for (i = 0; i < config._f.length; i++) {
+            currentScore = 0;
+            tempConfig = extend({}, config);
+            tempConfig._pf = defaultParsingFlags();
+            tempConfig._f = config._f[i];
+            makeDateFromStringAndFormat(tempConfig);
+
+            if (!isValid(tempConfig)) {
+                continue;
+            }
+
+            // if there is any input that was not parsed add a penalty for that format
+            currentScore += tempConfig._pf.charsLeftOver;
+
+            //or tokens
+            currentScore += tempConfig._pf.unusedTokens.length * 10;
+
+            tempConfig._pf.score = currentScore;
+
+            if (scoreToBeat == null || currentScore < scoreToBeat) {
+                scoreToBeat = currentScore;
+                bestMoment = tempConfig;
+            }
+        }
+
+        extend(config, bestMoment || tempConfig);
+    }
+
+    // date from iso format
+    function parseISO(config) {
+        var i, l,
+            string = config._i,
+            match = isoRegex.exec(string);
+
+        if (match) {
+            config._pf.iso = true;
+            for (i = 0, l = isoDates.length; i < l; i++) {
+                if (isoDates[i][1].exec(string)) {
+                    // match[5] should be "T" or undefined
+                    config._f = isoDates[i][0] + (match[6] || " ");
+                    break;
+                }
+            }
+            for (i = 0, l = isoTimes.length; i < l; i++) {
+                if (isoTimes[i][1].exec(string)) {
+                    config._f += isoTimes[i][0];
+                    break;
+                }
+            }
+            if (string.match(parseTokenTimezone)) {
+                config._f += "Z";
+            }
+            makeDateFromStringAndFormat(config);
+        } else {
+            config._isValid = false;
+        }
+    }
+
+    // date from iso format or fallback
+    function makeDateFromString(config) {
+        parseISO(config);
+        if (config._isValid === false) {
+            delete config._isValid;
+            moment.createFromInputFallback(config);
+        }
+    }
+
+    function makeDateFromInput(config) {
+        var input = config._i,
+            matched = aspNetJsonRegex.exec(input);
+
+        if (input === undefined) {
+            config._d = new Date();
+        } else if (matched) {
+            config._d = new Date(+matched[1]);
+        } else if (typeof input === 'string') {
+            makeDateFromString(config);
+        } else if (isArray(input)) {
+            config._a = input.slice(0);
+            dateFromConfig(config);
+        } else if (isDate(input)) {
+            config._d = new Date(+input);
+        } else if (typeof(input) === 'object') {
+            dateFromObject(config);
+        } else if (typeof(input) === 'number') {
+            // from milliseconds
+            config._d = new Date(input);
+        } else {
+            moment.createFromInputFallback(config);
+        }
+    }
+
+    function makeDate(y, m, d, h, M, s, ms) {
+        //can't just apply() to create a date:
+        //http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply
+        var date = new Date(y, m, d, h, M, s, ms);
+
+        //the date constructor doesn't accept years < 1970
+        if (y < 1970) {
+            date.setFullYear(y);
+        }
+        return date;
+    }
+
+    function makeUTCDate(y) {
+        var date = new Date(Date.UTC.apply(null, arguments));
+        if (y < 1970) {
+            date.setUTCFullYear(y);
+        }
+        return date;
+    }
+
+    function parseWeekday(input, language) {
+        if (typeof input === 'string') {
+            if (!isNaN(input)) {
+                input = parseInt(input, 10);
+            }
+            else {
+                input = language.weekdaysParse(input);
+                if (typeof input !== 'number') {
+                    return null;
+                }
+            }
+        }
+        return input;
+    }
+
+    /************************************
+        Relative Time
+    ************************************/
+
+
+    // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize
+    function substituteTimeAgo(string, number, withoutSuffix, isFuture, lang) {
+        return lang.relativeTime(number || 1, !!withoutSuffix, string, isFuture);
+    }
+
+    function relativeTime(milliseconds, withoutSuffix, lang) {
+        var seconds = round(Math.abs(milliseconds) / 1000),
+            minutes = round(seconds / 60),
+            hours = round(minutes / 60),
+            days = round(hours / 24),
+            years = round(days / 365),
+            args = seconds < relativeTimeThresholds.s  && ['s', seconds] ||
+                minutes === 1 && ['m'] ||
+                minutes < relativeTimeThresholds.m && ['mm', minutes] ||
+                hours === 1 && ['h'] ||
+                hours < relativeTimeThresholds.h && ['hh', hours] ||
+                days === 1 && ['d'] ||
+                days <= relativeTimeThresholds.dd && ['dd', days] ||
+                days <= relativeTimeThresholds.dm && ['M'] ||
+                days < relativeTimeThresholds.dy && ['MM', round(days / 30)] ||
+                years === 1 && ['y'] || ['yy', years];
+        args[2] = withoutSuffix;
+        args[3] = milliseconds > 0;
+        args[4] = lang;
+        return substituteTimeAgo.apply({}, args);
+    }
+
+
+    /************************************
+        Week of Year
+    ************************************/
+
+
+    // firstDayOfWeek       0 = sun, 6 = sat
+    //                      the day of the week that starts the week
+    //                      (usually sunday or monday)
+    // firstDayOfWeekOfYear 0 = sun, 6 = sat
+    //                      the first week is the week that contains the first
+    //                      of this day of the week
+    //                      (eg. ISO weeks use thursday (4))
+    function weekOfYear(mom, firstDayOfWeek, firstDayOfWeekOfYear) {
+        var end = firstDayOfWeekOfYear - firstDayOfWeek,
+            daysToDayOfWeek = firstDayOfWeekOfYear - mom.day(),
+            adjustedMoment;
+
+
+        if (daysToDayOfWeek > end) {
+            daysToDayOfWeek -= 7;
+        }
+
+        if (daysToDayOfWeek < end - 7) {
+            daysToDayOfWeek += 7;
+        }
+
+        adjustedMoment = moment(mom).add('d', daysToDayOfWeek);
+        return {
+            week: Math.ceil(adjustedMoment.dayOfYear() / 7),
+            year: adjustedMoment.year()
+        };
+    }
+
+    //http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday
+    function dayOfYearFromWeeks(year, week, weekday, firstDayOfWeekOfYear, firstDayOfWeek) {
+        var d = makeUTCDate(year, 0, 1).getUTCDay(), daysToAdd, dayOfYear;
+
+        d = d === 0 ? 7 : d;
+        weekday = weekday != null ? weekday : firstDayOfWeek;
+        daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0) - (d < firstDayOfWeek ? 7 : 0);
+        dayOfYear = 7 * (week - 1) + (weekday - firstDayOfWeek) + daysToAdd + 1;
+
+        return {
+            year: dayOfYear > 0 ? year : year - 1,
+            dayOfYear: dayOfYear > 0 ?  dayOfYear : daysInYear(year - 1) + dayOfYear
+        };
+    }
+
+    /************************************
+        Top Level Functions
+    ************************************/
+
+    function makeMoment(config) {
+        var input = config._i,
+            format = config._f;
+
+        if (input === null || (format === undefined && input === '')) {
+            return moment.invalid({nullInput: true});
+        }
+
+        if (typeof input === 'string') {
+            config._i = input = getLangDefinition().preparse(input);
+        }
+
+        if (moment.isMoment(input)) {
+            config = cloneMoment(input);
+
+            config._d = new Date(+input._d);
+        } else if (format) {
+            if (isArray(format)) {
+                makeDateFromStringAndArray(config);
+            } else {
+                makeDateFromStringAndFormat(config);
+            }
+        } else {
+            makeDateFromInput(config);
+        }
+
+        return new Moment(config);
+    }
+
+    moment = function (input, format, lang, strict) {
+        var c;
+
+        if (typeof(lang) === "boolean") {
+            strict = lang;
+            lang = undefined;
+        }
+        // object construction must be done this way.
+        // https://github.com/moment/moment/issues/1423
+        c = {};
+        c._isAMomentObject = true;
+        c._i = input;
+        c._f = format;
+        c._l = lang;
+        c._strict = strict;
+        c._isUTC = false;
+        c._pf = defaultParsingFlags();
+
+        return makeMoment(c);
+    };
+
+    moment.suppressDeprecationWarnings = false;
+
+    moment.createFromInputFallback = deprecate(
+            "moment construction falls back to js Date. This is " +
+            "discouraged and will be removed in upcoming major " +
+            "release. Please refer to " +
+            "https://github.com/moment/moment/issues/1407 for more info.",
+            function (config) {
+        config._d = new Date(config._i);
+    });
+
+    // Pick a moment m from moments so that m[fn](other) is true for all
+    // other. This relies on the function fn to be transitive.
+    //
+    // moments should either be an array of moment objects or an array, whose
+    // first element is an array of moment objects.
+    function pickBy(fn, moments) {
+        var res, i;
+        if (moments.length === 1 && isArray(moments[0])) {
+            moments = moments[0];
+        }
+        if (!moments.length) {
+            return moment();
+        }
+        res = moments[0];
+        for (i = 1; i < moments.length; ++i) {
+            if (moments[i][fn](res)) {
+                res = moments[i];
+            }
+        }
+        return res;
+    }
+
+    moment.min = function () {
+        var args = [].slice.call(arguments, 0);
+
+        return pickBy('isBefore', args);
+    };
+
+    moment.max = function () {
+        var args = [].slice.call(arguments, 0);
+
+        return pickBy('isAfter', args);
+    };
+
+    // creating with utc
+    moment.utc = function (input, format, lang, strict) {
+        var c;
+
+        if (typeof(lang) === "boolean") {
+            strict = lang;
+            lang = undefined;
+        }
+        // object construction must be done this way.
+        // https://github.com/moment/moment/issues/1423
+        c = {};
+        c._isAMomentObject = true;
+        c._useUTC = true;
+        c._isUTC = true;
+        c._l = lang;
+        c._i = input;
+        c._f = format;
+        c._strict = strict;
+        c._pf = defaultParsingFlags();
+
+        return makeMoment(c).utc();
+    };
+
+    // creating with unix timestamp (in seconds)
+    moment.unix = function (input) {
+        return moment(input * 1000);
+    };
+
+    // duration
+    moment.duration = function (input, key) {
+        var duration = input,
+            // matching against regexp is expensive, do it on demand
+            match = null,
+            sign,
+            ret,
+            parseIso;
+
+        if (moment.isDuration(input)) {
+            duration = {
+                ms: input._milliseconds,
+                d: input._days,
+                M: input._months
+            };
+        } else if (typeof input === 'number') {
+            duration = {};
+            if (key) {
+                duration[key] = input;
+            } else {
+                duration.milliseconds = input;
+            }
+        } else if (!!(match = aspNetTimeSpanJsonRegex.exec(input))) {
+            sign = (match[1] === "-") ? -1 : 1;
+            duration = {
+                y: 0,
+                d: toInt(match[DATE]) * sign,
+                h: toInt(match[HOUR]) * sign,
+                m: toInt(match[MINUTE]) * sign,
+                s: toInt(match[SECOND]) * sign,
+                ms: toInt(match[MILLISECOND]) * sign
+            };
+        } else if (!!(match = isoDurationRegex.exec(input))) {
+            sign = (match[1] === "-") ? -1 : 1;
+            parseIso = function (inp) {
+                // We'd normally use ~~inp for this, but unfortunately it also
+                // converts floats to ints.
+                // inp may be undefined, so careful calling replace on it.
+                var res = inp && parseFloat(inp.replace(',', '.'));
+                // apply sign while we're at it
+                return (isNaN(res) ? 0 : res) * sign;
+            };
+            duration = {
+                y: parseIso(match[2]),
+                M: parseIso(match[3]),
+                d: parseIso(match[4]),
+                h: parseIso(match[5]),
+                m: parseIso(match[6]),
+                s: parseIso(match[7]),
+                w: parseIso(match[8])
+            };
+        }
+
+        ret = new Duration(duration);
+
+        if (moment.isDuration(input) && input.hasOwnProperty('_lang')) {
+            ret._lang = input._lang;
+        }
+
+        return ret;
+    };
+
+    // version number
+    moment.version = VERSION;
+
+    // default format
+    moment.defaultFormat = isoFormat;
+
+    // constant that refers to the ISO standard
+    moment.ISO_8601 = function () {};
+
+    // Plugins that add properties should also add the key here (null value),
+    // so we can properly clone ourselves.
+    moment.momentProperties = momentProperties;
+
+    // This function will be called whenever a moment is mutated.
+    // It is intended to keep the offset in sync with the timezone.
+    moment.updateOffset = function () {};
+
+    // This function allows you to set a threshold for relative time strings
+    moment.relativeTimeThreshold = function(threshold, limit) {
+      if (relativeTimeThresholds[threshold] === undefined) {
+        return false;
+      }
+      relativeTimeThresholds[threshold] = limit;
+      return true;
+    };
+
+    // This function will load languages and then set the global language.  If
+    // no arguments are passed in, it will simply return the current global
+    // language key.
+    moment.lang = function (key, values) {
+        var r;
+        if (!key) {
+            return moment.fn._lang._abbr;
+        }
+        if (values) {
+            loadLang(normalizeLanguage(key), values);
+        } else if (values === null) {
+            unloadLang(key);
+            key = 'en';
+        } else if (!languages[key]) {
+            getLangDefinition(key);
+        }
+        r = moment.duration.fn._lang = moment.fn._lang = getLangDefinition(key);
+        return r._abbr;
+    };
+
+    // returns language data
+    moment.langData = function (key) {
+        if (key && key._lang && key._lang._abbr) {
+            key = key._lang._abbr;
+        }
+        return getLangDefinition(key);
+    };
+
+    // compare moment object
+    moment.isMoment = function (obj) {
+        return obj instanceof Moment ||
+            (obj != null &&  obj.hasOwnProperty('_isAMomentObject'));
+    };
+
+    // for typechecking Duration objects
+    moment.isDuration = function (obj) {
+        return obj instanceof Duration;
+    };
+
+    for (i = lists.length - 1; i >= 0; --i) {
+        makeList(lists[i]);
+    }
+
+    moment.normalizeUnits = function (units) {
+        return normalizeUnits(units);
+    };
+
+    moment.invalid = function (flags) {
+        var m = moment.utc(NaN);
+        if (flags != null) {
+            extend(m._pf, flags);
+        }
+        else {
+            m._pf.userInvalidated = true;
+        }
+
+        return m;
+    };
+
+    moment.parseZone = function () {
+        return moment.apply(null, arguments).parseZone();
+    };
+
+    moment.parseTwoDigitYear = function (input) {
+        return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
+    };
+
+    /************************************
+        Moment Prototype
+    ************************************/
+
+
+    extend(moment.fn = Moment.prototype, {
+
+        clone : function () {
+            return moment(this);
+        },
+
+        valueOf : function () {
+            return +this._d + ((this._offset || 0) * 60000);
+        },
+
+        unix : function () {
+            return Math.floor(+this / 1000);
+        },
+
+        toString : function () {
+            return this.clone().lang('en').format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ");
+        },
+
+        toDate : function () {
+            return this._offset ? new Date(+this) : this._d;
+        },
+
+        toISOString : function () {
+            var m = moment(this).utc();
+            if (0 < m.year() && m.year() <= 9999) {
+                return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]');
+            } else {
+                return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]');
+            }
+        },
+
+        toArray : function () {
+            var m = this;
+            return [
+                m.year(),
+                m.month(),
+                m.date(),
+                m.hours(),
+                m.minutes(),
+                m.seconds(),
+                m.milliseconds()
+            ];
+        },
+
+        isValid : function () {
+            return isValid(this);
+        },
+
+        isDSTShifted : function () {
+
+            if (this._a) {
+                return this.isValid() && compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()) > 0;
+            }
+
+            return false;
+        },
+
+        parsingFlags : function () {
+            return extend({}, this._pf);
+        },
+
+        invalidAt: function () {
+            return this._pf.overflow;
+        },
+
+        utc : function () {
+            return this.zone(0);
+        },
+
+        local : function () {
+            this.zone(0);
+            this._isUTC = false;
+            return this;
+        },
+
+        format : function (inputString) {
+            var output = formatMoment(this, inputString || moment.defaultFormat);
+            return this.lang().postformat(output);
+        },
+
+        add : function (input, val) {
+            var dur;
+            // switch args to support add('s', 1) and add(1, 's')
+            if (typeof input === 'string' && typeof val === 'string') {
+                dur = moment.duration(isNaN(+val) ? +input : +val, isNaN(+val) ? val : input);
+            } else if (typeof input === 'string') {
+                dur = moment.duration(+val, input);
+            } else {
+                dur = moment.duration(input, val);
+            }
+            addOrSubtractDurationFromMoment(this, dur, 1);
+            return this;
+        },
+
+        subtract : function (input, val) {
+            var dur;
+            // switch args to support subtract('s', 1) and subtract(1, 's')
+            if (typeof input === 'string' && typeof val === 'string') {
+                dur = moment.duration(isNaN(+val) ? +input : +val, isNaN(+val) ? val : input);
+            } else if (typeof input === 'string') {
+                dur = moment.duration(+val, input);
+            } else {
+                dur = moment.duration(input, val);
+            }
+            addOrSubtractDurationFromMoment(this, dur, -1);
+            return this;
+        },
+
+        diff : function (input, units, asFloat) {
+            var that = makeAs(input, this),
+                zoneDiff = (this.zone() - that.zone()) * 6e4,
+                diff, output;
+
+            units = normalizeUnits(units);
+
+            if (units === 'year' || units === 'month') {
+                // average number of days in the months in the given dates
+                diff = (this.daysInMonth() + that.daysInMonth()) * 432e5; // 24 * 60 * 60 * 1000 / 2
+                // difference in months
+                output = ((this.year() - that.year()) * 12) + (this.month() - that.month());
+                // adjust by taking difference in days, average number of days
+                // and dst in the given months.
+                output += ((this - moment(this).startOf('month')) -
+                        (that - moment(that).startOf('month'))) / diff;
+                // same as above but with zones, to negate all dst
+                output -= ((this.zone() - moment(this).startOf('month').zone()) -
+                        (that.zone() - moment(that).startOf('month').zone())) * 6e4 / diff;
+                if (units === 'year') {
+                    output = output / 12;
+                }
+            } else {
+                diff = (this - that);
+                output = units === 'second' ? diff / 1e3 : // 1000
+                    units === 'minute' ? diff / 6e4 : // 1000 * 60
+                    units === 'hour' ? diff / 36e5 : // 1000 * 60 * 60
+                    units === 'day' ? (diff - zoneDiff) / 864e5 : // 1000 * 60 * 60 * 24, negate dst
+                    units === 'week' ? (diff - zoneDiff) / 6048e5 : // 1000 * 60 * 60 * 24 * 7, negate dst
+                    diff;
+            }
+            return asFloat ? output : absRound(output);
+        },
+
+        from : function (time, withoutSuffix) {
+            return moment.duration(this.diff(time)).lang(this.lang()._abbr).humanize(!withoutSuffix);
+        },
+
+        fromNow : function (withoutSuffix) {
+            return this.from(moment(), withoutSuffix);
+        },
+
+        calendar : function (time) {
+            // We want to compare the start of today, vs this.
+            // Getting start-of-today depends on whether we're zone'd or not.
+            var now = time || moment(),
+                sod = makeAs(now, this).startOf('day'),
+                diff = this.diff(sod, 'days', true),
+                format = diff < -6 ? 'sameElse' :
+                    diff < -1 ? 'lastWeek' :
+                    diff < 0 ? 'lastDay' :
+                    diff < 1 ? 'sameDay' :
+                    diff < 2 ? 'nextDay' :
+                    diff < 7 ? 'nextWeek' : 'sameElse';
+            return this.format(this.lang().calendar(format, this));
+        },
+
+        isLeapYear : function () {
+            return isLeapYear(this.year());
+        },
+
+        isDST : function () {
+            return (this.zone() < this.clone().month(0).zone() ||
+                this.zone() < this.clone().month(5).zone());
+        },
+
+        day : function (input) {
+            var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay();
+            if (input != null) {
+                input = parseWeekday(input, this.lang());
+                return this.add({ d : input - day });
+            } else {
+                return day;
+            }
+        },
+
+        month : makeAccessor('Month', true),
+
+        startOf: function (units) {
+            units = normalizeUnits(units);
+            // the following switch intentionally omits break keywords
+            // to utilize falling through the cases.
+            switch (units) {
+            case 'year':
+                this.month(0);
+                /* falls through */
+            case 'quarter':
+            case 'month':
+                this.date(1);
+                /* falls through */
+            case 'week':
+            case 'isoWeek':
+            case 'day':
+                this.hours(0);
+                /* falls through */
+            case 'hour':
+                this.minutes(0);
+                /* falls through */
+            case 'minute':
+                this.seconds(0);
+                /* falls through */
+            case 'second':
+                this.milliseconds(0);
+                /* falls through */
+            }
+
+            // weeks are a special case
+            if (units === 'week') {
+                this.weekday(0);
+            } else if (units === 'isoWeek') {
+                this.isoWeekday(1);
+            }
+
+            // quarters are also special
+            if (units === 'quarter') {
+                this.month(Math.floor(this.month() / 3) * 3);
+            }
+
+            return this;
+        },
+
+        endOf: function (units) {
+            units = normalizeUnits(units);
+            return this.startOf(units).add((units === 'isoWeek' ? 'week' : units), 1).subtract('ms', 1);
+        },
+
+        isAfter: function (input, units) {
+            units = typeof units !== 'undefined' ? units : 'millisecond';
+            return +this.clone().startOf(units) > +moment(input).startOf(units);
+        },
+
+        isBefore: function (input, units) {
+            units = typeof units !== 'undefined' ? units : 'millisecond';
+            return +this.clone().startOf(units) < +moment(input).startOf(units);
+        },
+
+        isSame: function (input, units) {
+            units = units || 'ms';
+            return +this.clone().startOf(units) === +makeAs(input, this).startOf(units);
+        },
+
+        min: deprecate(
+                 "moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548",
+                 function (other) {
+                     other = moment.apply(null, arguments);
+                     return other < this ? this : other;
+                 }
+         ),
+
+        max: deprecate(
+                "moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548",
+                function (other) {
+                    other = moment.apply(null, arguments);
+                    return other > this ? this : other;
+                }
+        ),
+
+        // keepTime = true means only change the timezone, without affecting
+        // the local hour. So 5:31:26 +0300 --[zone(2, true)]--> 5:31:26 +0200
+        // It is possible that 5:31:26 doesn't exist int zone +0200, so we
+        // adjust the time as needed, to be valid.
+        //
+        // Keeping the time actually adds/subtracts (one hour)
+        // from the actual represented time. That is why we call updateOffset
+        // a second time. In case it wants us to change the offset again
+        // _changeInProgress == true case, then we have to adjust, because
+        // there is no such time in the given timezone.
+        zone : function (input, keepTime) {
+            var offset = this._offset || 0;
+            if (input != null) {
+                if (typeof input === "string") {
+                    input = timezoneMinutesFromString(input);
+                }
+                if (Math.abs(input) < 16) {
+                    input = input * 60;
+                }
+                this._offset = input;
+                this._isUTC = true;
+                if (offset !== input) {
+                    if (!keepTime || this._changeInProgress) {
+                        addOrSubtractDurationFromMoment(this,
+                                moment.duration(offset - input, 'm'), 1, false);
+                    } else if (!this._changeInProgress) {
+                        this._changeInProgress = true;
+                        moment.updateOffset(this, true);
+                        this._changeInProgress = null;
+                    }
+                }
+            } else {
+                return this._isUTC ? offset : this._d.getTimezoneOffset();
+            }
+            return this;
+        },
+
+        zoneAbbr : function () {
+            return this._isUTC ? "UTC" : "";
+        },
+
+        zoneName : function () {
+            return this._isUTC ? "Coordinated Universal Time" : "";
+        },
+
+        parseZone : function () {
+            if (this._tzm) {
+                this.zone(this._tzm);
+            } else if (typeof this._i === 'string') {
+                this.zone(this._i);
+            }
+            return this;
+        },
+
+        hasAlignedHourOffset : function (input) {
+            if (!input) {
+                input = 0;
+            }
+            else {
+                input = moment(input).zone();
+            }
+
+            return (this.zone() - input) % 60 === 0;
+        },
+
+        daysInMonth : function () {
+            return daysInMonth(this.year(), this.month());
+        },
+
+        dayOfYear : function (input) {
+            var dayOfYear = round((moment(this).startOf('day') - moment(this).startOf('year')) / 864e5) + 1;
+            return input == null ? dayOfYear : this.add("d", (input - dayOfYear));
+        },
+
+        quarter : function (input) {
+            return input == null ? Math.ceil((this.month() + 1) / 3) : this.month((input - 1) * 3 + this.month() % 3);
+        },
+
+        weekYear : function (input) {
+            var year = weekOfYear(this, this.lang()._week.dow, this.lang()._week.doy).year;
+            return input == null ? year : this.add("y", (input - year));
+        },
+
+        isoWeekYear : function (input) {
+            var year = weekOfYear(this, 1, 4).year;
+            return input == null ? year : this.add("y", (input - year));
+        },
+
+        week : function (input) {
+            var week = this.lang().week(this);
+            return input == null ? week : this.add("d", (input - week) * 7);
+        },
+
+        isoWeek : function (input) {
+            var week = weekOfYear(this, 1, 4).week;
+            return input == null ? week : this.add("d", (input - week) * 7);
+        },
+
+        weekday : function (input) {
+            var weekday = (this.day() + 7 - this.lang()._week.dow) % 7;
+            return input == null ? weekday : this.add("d", input - weekday);
+        },
+
+        isoWeekday : function (input) {
+            // behaves the same as moment#day except
+            // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6)
+            // as a setter, sunday should belong to the previous week.
+            return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7);
+        },
+
+        isoWeeksInYear : function () {
+            return weeksInYear(this.year(), 1, 4);
+        },
+
+        weeksInYear : function () {
+            var weekInfo = this._lang._week;
+            return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy);
+        },
+
+        get : function (units) {
+            units = normalizeUnits(units);
+            return this[units]();
+        },
+
+        set : function (units, value) {
+            units = normalizeUnits(units);
+            if (typeof this[units] === 'function') {
+                this[units](value);
+            }
+            return this;
+        },
+
+        // If passed a language key, it will set the language for this
+        // instance.  Otherwise, it will return the language configuration
+        // variables for this instance.
+        lang : function (key) {
+            if (key === undefined) {
+                return this._lang;
+            } else {
+                this._lang = getLangDefinition(key);
+                return this;
+            }
+        }
+    });
+
+    function rawMonthSetter(mom, value) {
+        var dayOfMonth;
+
+        // TODO: Move this out of here!
+        if (typeof value === 'string') {
+            value = mom.lang().monthsParse(value);
+            // TODO: Another silent failure?
+            if (typeof value !== 'number') {
+                return mom;
+            }
+        }
+
+        dayOfMonth = Math.min(mom.date(),
+                daysInMonth(mom.year(), value));
+        mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth);
+        return mom;
+    }
+
+    function rawGetter(mom, unit) {
+        return mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]();
+    }
+
+    function rawSetter(mom, unit, value) {
+        if (unit === 'Month') {
+            return rawMonthSetter(mom, value);
+        } else {
+            return mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value);
+        }
+    }
+
+    function makeAccessor(unit, keepTime) {
+        return function (value) {
+            if (value != null) {
+                rawSetter(this, unit, value);
+                moment.updateOffset(this, keepTime);
+                return this;
+            } else {
+                return rawGetter(this, unit);
+            }
+        };
+    }
+
+    moment.fn.millisecond = moment.fn.milliseconds = makeAccessor('Milliseconds', false);
+    moment.fn.second = moment.fn.seconds = makeAccessor('Seconds', false);
+    moment.fn.minute = moment.fn.minutes = makeAccessor('Minutes', false);
+    // Setting the hour should keep the time, because the user explicitly
+    // specified which hour he wants. So trying to maintain the same hour (in
+    // a new timezone) makes sense. Adding/subtracting hours does not follow
+    // this rule.
+    moment.fn.hour = moment.fn.hours = makeAccessor('Hours', true);
+    // moment.fn.month is defined separately
+    moment.fn.date = makeAccessor('Date', true);
+    moment.fn.dates = deprecate("dates accessor is deprecated. Use date instead.", makeAccessor('Date', true));
+    moment.fn.year = makeAccessor('FullYear', true);
+    moment.fn.years = deprecate("years accessor is deprecated. Use year instead.", makeAccessor('FullYear', true));
+
+    // add plural methods
+    moment.fn.days = moment.fn.day;
+    moment.fn.months = moment.fn.month;
+    moment.fn.weeks = moment.fn.week;
+    moment.fn.isoWeeks = moment.fn.isoWeek;
+    moment.fn.quarters = moment.fn.quarter;
+
+    // add aliased format methods
+    moment.fn.toJSON = moment.fn.toISOString;
+
+    /************************************
+        Duration Prototype
+    ************************************/
+
+
+    extend(moment.duration.fn = Duration.prototype, {
+
+        _bubble : function () {
+            var milliseconds = this._milliseconds,
+                days = this._days,
+                months = this._months,
+                data = this._data,
+                seconds, minutes, hours, years;
+
+            // The following code bubbles up values, see the tests for
+            // examples of what that means.
+            data.milliseconds = milliseconds % 1000;
+
+            seconds = absRound(milliseconds / 1000);
+            data.seconds = seconds % 60;
+
+            minutes = absRound(seconds / 60);
+            data.minutes = minutes % 60;
+
+            hours = absRound(minutes / 60);
+            data.hours = hours % 24;
+
+            days += absRound(hours / 24);
+            data.days = days % 30;
+
+            months += absRound(days / 30);
+            data.months = months % 12;
+
+            years = absRound(months / 12);
+            data.years = years;
+        },
+
+        weeks : function () {
+            return absRound(this.days() / 7);
+        },
+
+        valueOf : function () {
+            return this._milliseconds +
+              this._days * 864e5 +
+              (this._months % 12) * 2592e6 +
+              toInt(this._months / 12) * 31536e6;
+        },
+
+        humanize : function (withSuffix) {
+            var difference = +this,
+                output = relativeTime(difference, !withSuffix, this.lang());
+
+            if (withSuffix) {
+                output = this.lang().pastFuture(difference, output);
+            }
+
+            return this.lang().postformat(output);
+        },
+
+        add : function (input, val) {
+            // supports only 2.0-style add(1, 's') or add(moment)
+            var dur = moment.duration(input, val);
+
+            this._milliseconds += dur._milliseconds;
+            this._days += dur._days;
+            this._months += dur._months;
+
+            this._bubble();
+
+            return this;
+        },
+
+        subtract : function (input, val) {
+            var dur = moment.duration(input, val);
+
+            this._milliseconds -= dur._milliseconds;
+            this._days -= dur._days;
+            this._months -= dur._months;
+
+            this._bubble();
+
+            return this;
+        },
+
+        get : function (units) {
+            units = normalizeUnits(units);
+            return this[units.toLowerCase() + 's']();
+        },
+
+        as : function (units) {
+            units = normalizeUnits(units);
+            return this['as' + units.charAt(0).toUpperCase() + units.slice(1) + 's']();
+        },
+
+        lang : moment.fn.lang,
+
+        toIsoString : function () {
+            // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js
+            var years = Math.abs(this.years()),
+                months = Math.abs(this.months()),
+                days = Math.abs(this.days()),
+                hours = Math.abs(this.hours()),
+                minutes = Math.abs(this.minutes()),
+                seconds = Math.abs(this.seconds() + this.milliseconds() / 1000);
+
+            if (!this.asSeconds()) {
+                // this is the same as C#'s (Noda) and python (isodate)...
+                // but not other JS (goog.date)
+                return 'P0D';
+            }
+
+            return (this.asSeconds() < 0 ? '-' : '') +
+                'P' +
+                (years ? years + 'Y' : '') +
+                (months ? months + 'M' : '') +
+                (days ? days + 'D' : '') +
+                ((hours || minutes || seconds) ? 'T' : '') +
+                (hours ? hours + 'H' : '') +
+                (minutes ? minutes + 'M' : '') +
+                (seconds ? seconds + 'S' : '');
+        }
+    });
+
+    function makeDurationGetter(name) {
+        moment.duration.fn[name] = function () {
+            return this._data[name];
+        };
+    }
+
+    function makeDurationAsGetter(name, factor) {
+        moment.duration.fn['as' + name] = function () {
+            return +this / factor;
+        };
+    }
+
+    for (i in unitMillisecondFactors) {
+        if (unitMillisecondFactors.hasOwnProperty(i)) {
+            makeDurationAsGetter(i, unitMillisecondFactors[i]);
+            makeDurationGetter(i.toLowerCase());
+        }
+    }
+
+    makeDurationAsGetter('Weeks', 6048e5);
+    moment.duration.fn.asMonths = function () {
+        return (+this - this.years() * 31536e6) / 2592e6 + this.years() * 12;
+    };
+
+
+    /************************************
+        Default Lang
+    ************************************/
+
+
+    // Set default language, other languages will inherit from English.
+    moment.lang('en', {
+        ordinal : function (number) {
+            var b = number % 10,
+                output = (toInt(number % 100 / 10) === 1) ? 'th' :
+                (b === 1) ? 'st' :
+                (b === 2) ? 'nd' :
+                (b === 3) ? 'rd' : 'th';
+            return number + output;
+        }
+    });
+
+    /* EMBED_LANGUAGES */
+
+    /************************************
+        Exposing Moment
+    ************************************/
+
+    function makeGlobal(shouldDeprecate) {
+        /*global ender:false */
+        if (typeof ender !== 'undefined') {
+            return;
+        }
+        oldGlobalMoment = globalScope.moment;
+        if (shouldDeprecate) {
+            globalScope.moment = deprecate(
+                    "Accessing Moment through the global scope is " +
+                    "deprecated, and will be removed in an upcoming " +
+                    "release.",
+                    moment);
+        } else {
+            globalScope.moment = moment;
+        }
+    }
+
+    // CommonJS module is defined
+    if (hasModule) {
+        module.exports = moment;
+    } else if (typeof define === "function" && define.amd) {
+        define("moment", function (require, exports, module) {
+            if (module.config && module.config() && module.config().noGlobal === true) {
+                // release the global variable
+                globalScope.moment = oldGlobalMoment;
+            }
+
+            return moment;
+        });
+        makeGlobal(true);
+    } else {
+        makeGlobal();
+    }
+}).call(this);

+ 951 - 0
public/javascripts/mousetrap.js

@@ -0,0 +1,951 @@
+/*global define:false */
+/**
+ * Copyright 2013 Craig Campbell
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Mousetrap is a simple keyboard shortcut library for Javascript with
+ * no external dependencies
+ *
+ * @version 1.4.6
+ * @url craig.is/killing/mice
+ */
+(function(window, document, undefined) {
+
+    /**
+     * mapping of special keycodes to their corresponding keys
+     *
+     * everything in this dictionary cannot use keypress events
+     * so it has to be here to map to the correct keycodes for
+     * keyup/keydown events
+     *
+     * @type {Object}
+     */
+    var _MAP = {
+            8: 'backspace',
+            9: 'tab',
+            13: 'enter',
+            16: 'shift',
+            17: 'ctrl',
+            18: 'alt',
+            20: 'capslock',
+            27: 'esc',
+            32: 'space',
+            33: 'pageup',
+            34: 'pagedown',
+            35: 'end',
+            36: 'home',
+            37: 'left',
+            38: 'up',
+            39: 'right',
+            40: 'down',
+            45: 'ins',
+            46: 'del',
+            91: 'meta',
+            93: 'meta',
+            224: 'meta'
+        },
+
+        /**
+         * mapping for special characters so they can support
+         *
+         * this dictionary is only used incase you want to bind a
+         * keyup or keydown event to one of these keys
+         *
+         * @type {Object}
+         */
+        _KEYCODE_MAP = {
+            106: '*',
+            107: '+',
+            109: '-',
+            110: '.',
+            111 : '/',
+            186: ';',
+            187: '=',
+            188: ',',
+            189: '-',
+            190: '.',
+            191: '/',
+            192: '`',
+            219: '[',
+            220: '\\',
+            221: ']',
+            222: '\''
+        },
+
+        /**
+         * this is a mapping of keys that require shift on a US keypad
+         * back to the non shift equivelents
+         *
+         * this is so you can use keyup events with these keys
+         *
+         * note that this will only work reliably on US keyboards
+         *
+         * @type {Object}
+         */
+        _SHIFT_MAP = {
+            '~': '`',
+            '!': '1',
+            '@': '2',
+            '#': '3',
+            '$': '4',
+            '%': '5',
+            '^': '6',
+            '&': '7',
+            '*': '8',
+            '(': '9',
+            ')': '0',
+            '_': '-',
+            '+': '=',
+            ':': ';',
+            '\"': '\'',
+            '<': ',',
+            '>': '.',
+            '?': '/',
+            '|': '\\'
+        },
+
+        /**
+         * this is a list of special strings you can use to map
+         * to modifier keys when you specify your keyboard shortcuts
+         *
+         * @type {Object}
+         */
+        _SPECIAL_ALIASES = {
+            'option': 'alt',
+            'command': 'meta',
+            'return': 'enter',
+            'escape': 'esc',
+            'mod': /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl'
+        },
+
+        /**
+         * variable to store the flipped version of _MAP from above
+         * needed to check if we should use keypress or not when no action
+         * is specified
+         *
+         * @type {Object|undefined}
+         */
+        _REVERSE_MAP,
+
+        /**
+         * a list of all the callbacks setup via Mousetrap.bind()
+         *
+         * @type {Object}
+         */
+        _callbacks = {},
+
+        /**
+         * direct map of string combinations to callbacks used for trigger()
+         *
+         * @type {Object}
+         */
+        _directMap = {},
+
+        /**
+         * keeps track of what level each sequence is at since multiple
+         * sequences can start out with the same sequence
+         *
+         * @type {Object}
+         */
+        _sequenceLevels = {},
+
+        /**
+         * variable to store the setTimeout call
+         *
+         * @type {null|number}
+         */
+        _resetTimer,
+
+        /**
+         * temporary state where we will ignore the next keyup
+         *
+         * @type {boolean|string}
+         */
+        _ignoreNextKeyup = false,
+
+        /**
+         * temporary state where we will ignore the next keypress
+         *
+         * @type {boolean}
+         */
+        _ignoreNextKeypress = false,
+
+        /**
+         * are we currently inside of a sequence?
+         * type of action ("keyup" or "keydown" or "keypress") or false
+         *
+         * @type {boolean|string}
+         */
+        _nextExpectedAction = false;
+
+    /**
+     * loop through the f keys, f1 to f19 and add them to the map
+     * programatically
+     */
+    for (var i = 1; i < 20; ++i) {
+        _MAP[111 + i] = 'f' + i;
+    }
+
+    /**
+     * loop through to map numbers on the numeric keypad
+     */
+    for (i = 0; i <= 9; ++i) {
+        _MAP[i + 96] = i;
+    }
+
+    /**
+     * cross browser add event method
+     *
+     * @param {Element|HTMLDocument} object
+     * @param {string} type
+     * @param {Function} callback
+     * @returns void
+     */
+    function _addEvent(object, type, callback) {
+        if (object.addEventListener) {
+            object.addEventListener(type, callback, false);
+            return;
+        }
+
+        object.attachEvent('on' + type, callback);
+    }
+
+    /**
+     * takes the event and returns the key character
+     *
+     * @param {Event} e
+     * @return {string}
+     */
+    function _characterFromEvent(e) {
+
+        // for keypress events we should return the character as is
+        if (e.type == 'keypress') {
+            var character = String.fromCharCode(e.which);
+
+            // if the shift key is not pressed then it is safe to assume
+            // that we want the character to be lowercase.  this means if
+            // you accidentally have caps lock on then your key bindings
+            // will continue to work
+            //
+            // the only side effect that might not be desired is if you
+            // bind something like 'A' cause you want to trigger an
+            // event when capital A is pressed caps lock will no longer
+            // trigger the event.  shift+a will though.
+            if (!e.shiftKey) {
+                character = character.toLowerCase();
+            }
+
+            return character;
+        }
+
+        // for non keypress events the special maps are needed
+        if (_MAP[e.which]) {
+            return _MAP[e.which];
+        }
+
+        if (_KEYCODE_MAP[e.which]) {
+            return _KEYCODE_MAP[e.which];
+        }
+
+        // if it is not in the special map
+
+        // with keydown and keyup events the character seems to always
+        // come in as an uppercase character whether you are pressing shift
+        // or not.  we should make sure it is always lowercase for comparisons
+        return String.fromCharCode(e.which).toLowerCase();
+    }
+
+    /**
+     * checks if two arrays are equal
+     *
+     * @param {Array} modifiers1
+     * @param {Array} modifiers2
+     * @returns {boolean}
+     */
+    function _modifiersMatch(modifiers1, modifiers2) {
+        return modifiers1.sort().join(',') === modifiers2.sort().join(',');
+    }
+
+    /**
+     * resets all sequence counters except for the ones passed in
+     *
+     * @param {Object} doNotReset
+     * @returns void
+     */
+    function _resetSequences(doNotReset) {
+        doNotReset = doNotReset || {};
+
+        var activeSequences = false,
+            key;
+
+        for (key in _sequenceLevels) {
+            if (doNotReset[key]) {
+                activeSequences = true;
+                continue;
+            }
+            _sequenceLevels[key] = 0;
+        }
+
+        if (!activeSequences) {
+            _nextExpectedAction = false;
+        }
+    }
+
+    /**
+     * finds all callbacks that match based on the keycode, modifiers,
+     * and action
+     *
+     * @param {string} character
+     * @param {Array} modifiers
+     * @param {Event|Object} e
+     * @param {string=} sequenceName - name of the sequence we are looking for
+     * @param {string=} combination
+     * @param {number=} level
+     * @returns {Array}
+     */
+    function _getMatches(character, modifiers, e, sequenceName, combination, level) {
+        var i,
+            callback,
+            matches = [],
+            action = e.type;
+
+        // if there are no events related to this keycode
+        if (!_callbacks[character]) {
+            return [];
+        }
+
+        // if a modifier key is coming up on its own we should allow it
+        if (action == 'keyup' && _isModifier(character)) {
+            modifiers = [character];
+        }
+
+        // loop through all callbacks for the key that was pressed
+        // and see if any of them match
+        for (i = 0; i < _callbacks[character].length; ++i) {
+            callback = _callbacks[character][i];
+
+            // if a sequence name is not specified, but this is a sequence at
+            // the wrong level then move onto the next match
+            if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) {
+                continue;
+            }
+
+            // if the action we are looking for doesn't match the action we got
+            // then we should keep going
+            if (action != callback.action) {
+                continue;
+            }
+
+            // if this is a keypress event and the meta key and control key
+            // are not pressed that means that we need to only look at the
+            // character, otherwise check the modifiers as well
+            //
+            // chrome will not fire a keypress if meta or control is down
+            // safari will fire a keypress if meta or meta+shift is down
+            // firefox will fire a keypress if meta or control is down
+            if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) {
+
+                // when you bind a combination or sequence a second time it
+                // should overwrite the first one.  if a sequenceName or
+                // combination is specified in this call it does just that
+                //
+                // @todo make deleting its own method?
+                var deleteCombo = !sequenceName && callback.combo == combination;
+                var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level;
+                if (deleteCombo || deleteSequence) {
+                    _callbacks[character].splice(i, 1);
+                }
+
+                matches.push(callback);
+            }
+        }
+
+        return matches;
+    }
+
+    /**
+     * takes a key event and figures out what the modifiers are
+     *
+     * @param {Event} e
+     * @returns {Array}
+     */
+    function _eventModifiers(e) {
+        var modifiers = [];
+
+        if (e.shiftKey) {
+            modifiers.push('shift');
+        }
+
+        if (e.altKey) {
+            modifiers.push('alt');
+        }
+
+        if (e.ctrlKey) {
+            modifiers.push('ctrl');
+        }
+
+        if (e.metaKey) {
+            modifiers.push('meta');
+        }
+
+        return modifiers;
+    }
+
+    /**
+     * prevents default for this event
+     *
+     * @param {Event} e
+     * @returns void
+     */
+    function _preventDefault(e) {
+        if (e.preventDefault) {
+            e.preventDefault();
+            return;
+        }
+
+        e.returnValue = false;
+    }
+
+    /**
+     * stops propogation for this event
+     *
+     * @param {Event} e
+     * @returns void
+     */
+    function _stopPropagation(e) {
+        if (e.stopPropagation) {
+            e.stopPropagation();
+            return;
+        }
+
+        e.cancelBubble = true;
+    }
+
+    /**
+     * actually calls the callback function
+     *
+     * if your callback function returns false this will use the jquery
+     * convention - prevent default and stop propogation on the event
+     *
+     * @param {Function} callback
+     * @param {Event} e
+     * @returns void
+     */
+    function _fireCallback(callback, e, combo, sequence) {
+
+        // if this event should not happen stop here
+        if (Mousetrap.stopCallback(e, e.target || e.srcElement, combo, sequence)) {
+            return;
+        }
+
+        if (callback(e, combo) === false) {
+            _preventDefault(e);
+            _stopPropagation(e);
+        }
+    }
+
+    /**
+     * handles a character key event
+     *
+     * @param {string} character
+     * @param {Array} modifiers
+     * @param {Event} e
+     * @returns void
+     */
+    function _handleKey(character, modifiers, e) {
+        var callbacks = _getMatches(character, modifiers, e),
+            i,
+            doNotReset = {},
+            maxLevel = 0,
+            processedSequenceCallback = false;
+
+        // Calculate the maxLevel for sequences so we can only execute the longest callback sequence
+        for (i = 0; i < callbacks.length; ++i) {
+            if (callbacks[i].seq) {
+                maxLevel = Math.max(maxLevel, callbacks[i].level);
+            }
+        }
+
+        // loop through matching callbacks for this key event
+        for (i = 0; i < callbacks.length; ++i) {
+
+            // fire for all sequence callbacks
+            // this is because if for example you have multiple sequences
+            // bound such as "g i" and "g t" they both need to fire the
+            // callback for matching g cause otherwise you can only ever
+            // match the first one
+            if (callbacks[i].seq) {
+
+                // only fire callbacks for the maxLevel to prevent
+                // subsequences from also firing
+                //
+                // for example 'a option b' should not cause 'option b' to fire
+                // even though 'option b' is part of the other sequence
+                //
+                // any sequences that do not match here will be discarded
+                // below by the _resetSequences call
+                if (callbacks[i].level != maxLevel) {
+                    continue;
+                }
+
+                processedSequenceCallback = true;
+
+                // keep a list of which sequences were matches for later
+                doNotReset[callbacks[i].seq] = 1;
+                _fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq);
+                continue;
+            }
+
+            // if there were no sequence matches but we are still here
+            // that means this is a regular match so we should fire that
+            if (!processedSequenceCallback) {
+                _fireCallback(callbacks[i].callback, e, callbacks[i].combo);
+            }
+        }
+
+        // if the key you pressed matches the type of sequence without
+        // being a modifier (ie "keyup" or "keypress") then we should
+        // reset all sequences that were not matched by this event
+        //
+        // this is so, for example, if you have the sequence "h a t" and you
+        // type "h e a r t" it does not match.  in this case the "e" will
+        // cause the sequence to reset
+        //
+        // modifier keys are ignored because you can have a sequence
+        // that contains modifiers such as "enter ctrl+space" and in most
+        // cases the modifier key will be pressed before the next key
+        //
+        // also if you have a sequence such as "ctrl+b a" then pressing the
+        // "b" key will trigger a "keypress" and a "keydown"
+        //
+        // the "keydown" is expected when there is a modifier, but the
+        // "keypress" ends up matching the _nextExpectedAction since it occurs
+        // after and that causes the sequence to reset
+        //
+        // we ignore keypresses in a sequence that directly follow a keydown
+        // for the same character
+        var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress;
+        if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) {
+            _resetSequences(doNotReset);
+        }
+
+        _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown';
+    }
+
+    /**
+     * handles a keydown event
+     *
+     * @param {Event} e
+     * @returns void
+     */
+    function _handleKeyEvent(e) {
+
+        // normalize e.which for key events
+        // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion
+        if (typeof e.which !== 'number') {
+            e.which = e.keyCode;
+        }
+
+        var character = _characterFromEvent(e);
+
+        // no character found then stop
+        if (!character) {
+            return;
+        }
+
+        // need to use === for the character check because the character can be 0
+        if (e.type == 'keyup' && _ignoreNextKeyup === character) {
+            _ignoreNextKeyup = false;
+            return;
+        }
+
+        Mousetrap.handleKey(character, _eventModifiers(e), e);
+    }
+
+    /**
+     * determines if the keycode specified is a modifier key or not
+     *
+     * @param {string} key
+     * @returns {boolean}
+     */
+    function _isModifier(key) {
+        return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta';
+    }
+
+    /**
+     * called to set a 1 second timeout on the specified sequence
+     *
+     * this is so after each key press in the sequence you have 1 second
+     * to press the next key before you have to start over
+     *
+     * @returns void
+     */
+    function _resetSequenceTimer() {
+        clearTimeout(_resetTimer);
+        _resetTimer = setTimeout(_resetSequences, 1000);
+    }
+
+    /**
+     * reverses the map lookup so that we can look for specific keys
+     * to see what can and can't use keypress
+     *
+     * @return {Object}
+     */
+    function _getReverseMap() {
+        if (!_REVERSE_MAP) {
+            _REVERSE_MAP = {};
+            for (var key in _MAP) {
+
+                // pull out the numeric keypad from here cause keypress should
+                // be able to detect the keys from the character
+                if (key > 95 && key < 112) {
+                    continue;
+                }
+
+                if (_MAP.hasOwnProperty(key)) {
+                    _REVERSE_MAP[_MAP[key]] = key;
+                }
+            }
+        }
+        return _REVERSE_MAP;
+    }
+
+    /**
+     * picks the best action based on the key combination
+     *
+     * @param {string} key - character for key
+     * @param {Array} modifiers
+     * @param {string=} action passed in
+     */
+    function _pickBestAction(key, modifiers, action) {
+
+        // if no action was picked in we should try to pick the one
+        // that we think would work best for this key
+        if (!action) {
+            action = _getReverseMap()[key] ? 'keydown' : 'keypress';
+        }
+
+        // modifier keys don't work as expected with keypress,
+        // switch to keydown
+        if (action == 'keypress' && modifiers.length) {
+            action = 'keydown';
+        }
+
+        return action;
+    }
+
+    /**
+     * binds a key sequence to an event
+     *
+     * @param {string} combo - combo specified in bind call
+     * @param {Array} keys
+     * @param {Function} callback
+     * @param {string=} action
+     * @returns void
+     */
+    function _bindSequence(combo, keys, callback, action) {
+
+        // start off by adding a sequence level record for this combination
+        // and setting the level to 0
+        _sequenceLevels[combo] = 0;
+
+        /**
+         * callback to increase the sequence level for this sequence and reset
+         * all other sequences that were active
+         *
+         * @param {string} nextAction
+         * @returns {Function}
+         */
+        function _increaseSequence(nextAction) {
+            return function() {
+                _nextExpectedAction = nextAction;
+                ++_sequenceLevels[combo];
+                _resetSequenceTimer();
+            };
+        }
+
+        /**
+         * wraps the specified callback inside of another function in order
+         * to reset all sequence counters as soon as this sequence is done
+         *
+         * @param {Event} e
+         * @returns void
+         */
+        function _callbackAndReset(e) {
+            _fireCallback(callback, e, combo);
+
+            // we should ignore the next key up if the action is key down
+            // or keypress.  this is so if you finish a sequence and
+            // release the key the final key will not trigger a keyup
+            if (action !== 'keyup') {
+                _ignoreNextKeyup = _characterFromEvent(e);
+            }
+
+            // weird race condition if a sequence ends with the key
+            // another sequence begins with
+            setTimeout(_resetSequences, 10);
+        }
+
+        // loop through keys one at a time and bind the appropriate callback
+        // function.  for any key leading up to the final one it should
+        // increase the sequence. after the final, it should reset all sequences
+        //
+        // if an action is specified in the original bind call then that will
+        // be used throughout.  otherwise we will pass the action that the
+        // next key in the sequence should match.  this allows a sequence
+        // to mix and match keypress and keydown events depending on which
+        // ones are better suited to the key provided
+        for (var i = 0; i < keys.length; ++i) {
+            var isFinal = i + 1 === keys.length;
+            var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action);
+            _bindSingle(keys[i], wrappedCallback, action, combo, i);
+        }
+    }
+
+    /**
+     * Converts from a string key combination to an array
+     *
+     * @param  {string} combination like "command+shift+l"
+     * @return {Array}
+     */
+    function _keysFromString(combination) {
+        var keys = combination.split('+');
+        if (combination[combination.length-1] === '+') {
+            keys.pop();
+            if (keys.length) keys.pop();
+            keys.push('+');
+        }
+
+        return keys;
+    }
+
+    /**
+     * Gets info for a specific key combination
+     *
+     * @param  {string} combination key combination ("command+s" or "a" or "*")
+     * @param  {string=} action
+     * @returns {Object}
+     */
+    function _getKeyInfo(combination, action) {
+        var keys,
+            key,
+            i,
+            modifiers = [];
+
+        // take the keys from this pattern and figure out what the actual
+        // pattern is all about
+        keys = _keysFromString(combination);
+
+        for (i = 0; i < keys.length; ++i) {
+            key = keys[i];
+
+            // normalize key names
+            if (_SPECIAL_ALIASES[key]) {
+                key = _SPECIAL_ALIASES[key];
+            }
+
+            // if this is not a keypress event then we should
+            // be smart about using shift keys
+            // this will only work for US keyboards however
+            if (action && action != 'keypress' && _SHIFT_MAP[key]) {
+                key = _SHIFT_MAP[key];
+                modifiers.push('shift');
+            }
+
+            // if this key is a modifier then add it to the list of modifiers
+            if (_isModifier(key)) {
+                modifiers.push(key);
+            }
+        }
+
+        // depending on what the key combination is
+        // we will try to pick the best event for it
+        action = _pickBestAction(key, modifiers, action);
+
+        return {
+            key: key,
+            modifiers: modifiers,
+            action: action
+        };
+    }
+
+    /**
+     * binds a single keyboard combination
+     *
+     * @param {string} combination
+     * @param {Function} callback
+     * @param {string=} action
+     * @param {string=} sequenceName - name of sequence if part of sequence
+     * @param {number=} level - what part of the sequence the command is
+     * @returns void
+     */
+    function _bindSingle(combination, callback, action, sequenceName, level) {
+
+        // store a direct mapped reference for use with Mousetrap.trigger
+        _directMap[combination + ':' + action] = callback;
+
+        // make sure multiple spaces in a row become a single space
+        combination = combination.replace(/\s+/g, ' ');
+
+        var sequence = combination.split(' '),
+            info;
+
+        // if this pattern is a sequence of keys then run through this method
+        // to reprocess each pattern one key at a time
+        if (sequence.length > 1) {
+            _bindSequence(combination, sequence, callback, action);
+            return;
+        }
+
+        info = _getKeyInfo(combination, action);
+
+        // make sure to initialize array if this is the first time
+        // a callback is added for this key
+        _callbacks[info.key] = _callbacks[info.key] || [];
+
+        // remove an existing match if there is one
+        _getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level);
+
+        // add this call back to the array
+        // if it is a sequence put it at the beginning
+        // if not put it at the end
+        //
+        // this is important because the way these are processed expects
+        // the sequence ones to come first
+        _callbacks[info.key][sequenceName ? 'unshift' : 'push']({
+            callback: callback,
+            modifiers: info.modifiers,
+            action: info.action,
+            seq: sequenceName,
+            level: level,
+            combo: combination
+        });
+    }
+
+    /**
+     * binds multiple combinations to the same callback
+     *
+     * @param {Array} combinations
+     * @param {Function} callback
+     * @param {string|undefined} action
+     * @returns void
+     */
+    function _bindMultiple(combinations, callback, action) {
+        for (var i = 0; i < combinations.length; ++i) {
+            _bindSingle(combinations[i], callback, action);
+        }
+    }
+
+    // start!
+    _addEvent(document, 'keypress', _handleKeyEvent);
+    _addEvent(document, 'keydown', _handleKeyEvent);
+    _addEvent(document, 'keyup', _handleKeyEvent);
+
+    var Mousetrap = {
+
+        /**
+         * binds an event to mousetrap
+         *
+         * can be a single key, a combination of keys separated with +,
+         * an array of keys, or a sequence of keys separated by spaces
+         *
+         * be sure to list the modifier keys first to make sure that the
+         * correct key ends up getting bound (the last key in the pattern)
+         *
+         * @param {string|Array} keys
+         * @param {Function} callback
+         * @param {string=} action - 'keypress', 'keydown', or 'keyup'
+         * @returns void
+         */
+        bind: function(keys, callback, action) {
+            keys = keys instanceof Array ? keys : [keys];
+            _bindMultiple(keys, callback, action);
+            return this;
+        },
+
+        /**
+         * unbinds an event to mousetrap
+         *
+         * the unbinding sets the callback function of the specified key combo
+         * to an empty function and deletes the corresponding key in the
+         * _directMap dict.
+         *
+         * TODO: actually remove this from the _callbacks dictionary instead
+         * of binding an empty function
+         *
+         * the keycombo+action has to be exactly the same as
+         * it was defined in the bind method
+         *
+         * @param {string|Array} keys
+         * @param {string} action
+         * @returns void
+         */
+        unbind: function(keys, action) {
+            return Mousetrap.bind(keys, function() {}, action);
+        },
+
+        /**
+         * triggers an event that has already been bound
+         *
+         * @param {string} keys
+         * @param {string=} action
+         * @returns void
+         */
+        trigger: function(keys, action) {
+            if (_directMap[keys + ':' + action]) {
+                _directMap[keys + ':' + action]({}, keys);
+            }
+            return this;
+        },
+
+        /**
+         * resets the library back to its initial state.  this is useful
+         * if you want to clear out the current keyboard shortcuts and bind
+         * new ones - for example if you switch to another page
+         *
+         * @returns void
+         */
+        reset: function() {
+            _callbacks = {};
+            _directMap = {};
+            return this;
+        },
+
+       /**
+        * should we stop this event before firing off callbacks
+        *
+        * @param {Event} e
+        * @param {Element} element
+        * @return {boolean}
+        */
+        stopCallback: function(e, element) {
+
+            // if the element has the class "mousetrap" then no need to stop
+            if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
+                return false;
+            }
+
+            // stop for input, select, and textarea
+            return element.tagName == 'INPUT' || element.tagName == 'SELECT' || element.tagName == 'TEXTAREA' || element.isContentEditable;
+        },
+
+        /**
+         * exposes _handleKey publicly so it can be overwritten by extensions
+         */
+        handleKey: _handleKey
+    };
+
+    // expose mousetrap to the global object
+    window.Mousetrap = Mousetrap;
+}) (window, document);

+ 147 - 0
public/javascripts/packer.growing.js

@@ -0,0 +1,147 @@
+/******************************************************************************
+
+This is a binary tree based bin packing algorithm that is more complex than
+the simple Packer (packer.js). Instead of starting off with a fixed width and
+height, it starts with the width and height of the first block passed and then
+grows as necessary to accomodate each subsequent block. As it grows it attempts
+to maintain a roughly square ratio by making 'smart' choices about whether to
+grow right or down.
+
+When growing, the algorithm can only grow to the right OR down. Therefore, if
+the new block is BOTH wider and taller than the current target then it will be
+rejected. This makes it very important to initialize with a sensible starting
+width and height. If you are providing sorted input (largest first) then this
+will not be an issue.
+
+A potential way to solve this limitation would be to allow growth in BOTH
+directions at once, but this requires maintaining a more complex tree
+with 3 children (down, right and center) and that complexity can be avoided
+by simply chosing a sensible starting block.
+
+Best results occur when the input blocks are sorted by height, or even better
+when sorted by max(width,height).
+
+Inputs:
+------
+
+  blocks: array of any objects that have .w and .h attributes
+
+Outputs:
+-------
+
+  marks each block that fits with a .fit attribute pointing to a
+  node with .x and .y coordinates
+
+Example:
+-------
+
+  var blocks = [
+    { w: 100, h: 100 },
+    { w: 100, h: 100 },
+    { w:  80, h:  80 },
+    { w:  80, h:  80 },
+    etc
+    etc
+  ];
+
+  var packer = new GrowingPacker();
+  packer.fit(blocks);
+
+  for(var n = 0 ; n < blocks.length ; n++) {
+    var block = blocks[n];
+    if (block.fit) {
+      Draw(block.fit.x, block.fit.y, block.w, block.h);
+    }
+  }
+
+
+******************************************************************************/
+
+GrowingPacker = function() { };
+
+GrowingPacker.prototype = {
+
+  fit: function(blocks) {
+    var n, node, block, len = blocks.length;
+    var w = len > 0 ? blocks[0].w : 0;
+    var h = len > 0 ? blocks[0].h : 0;
+    this.root = { x: 0, y: 0, w: w, h: h };
+    for (n = 0; n < len ; n++) {
+      block = blocks[n];
+      if (node = this.findNode(this.root, block.w, block.h))
+        block.fit = this.splitNode(node, block.w, block.h);
+      else
+        block.fit = this.growNode(block.w, block.h);
+    }
+  },
+
+  findNode: function(root, w, h) {
+    if (root.used)
+      return this.findNode(root.right, w, h) || this.findNode(root.down, w, h);
+    else if ((w <= root.w) && (h <= root.h))
+      return root;
+    else
+      return null;
+  },
+
+  splitNode: function(node, w, h) {
+    node.used = true;
+    node.down  = { x: node.x,     y: node.y + h, w: node.w,     h: node.h - h };
+    node.right = { x: node.x + w, y: node.y,     w: node.w - w, h: h          };
+    return node;
+  },
+
+  growNode: function(w, h) {
+    var canGrowDown  = (w <= this.root.w);
+    var canGrowRight = (h <= this.root.h);
+
+    var shouldGrowRight = canGrowRight && (this.root.h >= (this.root.w + w)); // attempt to keep square-ish by growing right when height is much greater than width
+    var shouldGrowDown  = canGrowDown  && (this.root.w >= (this.root.h + h)); // attempt to keep square-ish by growing down  when width  is much greater than height
+
+    if (shouldGrowRight)
+      return this.growRight(w, h);
+    else if (shouldGrowDown)
+      return this.growDown(w, h);
+    else if (canGrowRight)
+     return this.growRight(w, h);
+    else if (canGrowDown)
+      return this.growDown(w, h);
+    else
+      return null; // need to ensure sensible root starting size to avoid this happening
+  },
+
+  growRight: function(w, h) {
+    this.root = {
+      used: true,
+      x: 0,
+      y: 0,
+      w: this.root.w + w,
+      h: this.root.h,
+      down: this.root,
+      right: { x: this.root.w, y: 0, w: w, h: this.root.h }
+    };
+    if (node = this.findNode(this.root, w, h))
+      return this.splitNode(node, w, h);
+    else
+      return null;
+  },
+
+  growDown: function(w, h) {
+    this.root = {
+      used: true,
+      x: 0,
+      y: 0,
+      w: this.root.w,
+      h: this.root.h + h,
+      down:  { x: 0, y: this.root.h, w: this.root.w, h: h },
+      right: this.root
+    };
+    if (node = this.findNode(this.root, w, h))
+      return this.splitNode(node, w, h);
+    else
+      return null;
+  }
+
+}
+
+

+ 627 - 0
public/javascripts/route-recognizer.js

@@ -0,0 +1,627 @@
+(function(__exports__) {
+  "use strict";
+  var specials = [
+    '/', '.', '*', '+', '?', '|',
+    '(', ')', '[', ']', '{', '}', '\\'
+  ];
+
+  var escapeRegex = new RegExp('(\\' + specials.join('|\\') + ')', 'g');
+
+  function isArray(test) {
+    return Object.prototype.toString.call(test) === "[object Array]";
+  }
+
+  // A Segment represents a segment in the original route description.
+  // Each Segment type provides an `eachChar` and `regex` method.
+  //
+  // The `eachChar` method invokes the callback with one or more character
+  // specifications. A character specification consumes one or more input
+  // characters.
+  //
+  // The `regex` method returns a regex fragment for the segment. If the
+  // segment is a dynamic of star segment, the regex fragment also includes
+  // a capture.
+  //
+  // A character specification contains:
+  //
+  // * `validChars`: a String with a list of all valid characters, or
+  // * `invalidChars`: a String with a list of all invalid characters
+  // * `repeat`: true if the character specification can repeat
+
+  function StaticSegment(string) { this.string = string; }
+  StaticSegment.prototype = {
+    eachChar: function(callback) {
+      var string = this.string, ch;
+
+      for (var i=0, l=string.length; i<l; i++) {
+        ch = string.charAt(i);
+        callback({ validChars: ch });
+      }
+    },
+
+    regex: function() {
+      return this.string.replace(escapeRegex, '\\$1');
+    },
+
+    generate: function() {
+      return this.string;
+    }
+  };
+
+  function DynamicSegment(name) { this.name = name; }
+  DynamicSegment.prototype = {
+    eachChar: function(callback) {
+      callback({ invalidChars: "/", repeat: true });
+    },
+
+    regex: function() {
+      return "([^/]+)";
+    },
+
+    generate: function(params) {
+      return params[this.name];
+    }
+  };
+
+  function StarSegment(name) { this.name = name; }
+  StarSegment.prototype = {
+    eachChar: function(callback) {
+      callback({ invalidChars: "", repeat: true });
+    },
+
+    regex: function() {
+      return "(.+)";
+    },
+
+    generate: function(params) {
+      return params[this.name];
+    }
+  };
+
+  function EpsilonSegment() {}
+  EpsilonSegment.prototype = {
+    eachChar: function() {},
+    regex: function() { return ""; },
+    generate: function() { return ""; }
+  };
+
+  function parse(route, names, types) {
+    // normalize route as not starting with a "/". Recognition will
+    // also normalize.
+    if (route.charAt(0) === "/") { route = route.substr(1); }
+
+    var segments = route.split("/"), results = [];
+
+    for (var i=0, l=segments.length; i<l; i++) {
+      var segment = segments[i], match;
+
+      if (match = segment.match(/^:([^\/]+)$/)) {
+        results.push(new DynamicSegment(match[1]));
+        names.push(match[1]);
+        types.dynamics++;
+      } else if (match = segment.match(/^\*([^\/]+)$/)) {
+        results.push(new StarSegment(match[1]));
+        names.push(match[1]);
+        types.stars++;
+      } else if(segment === "") {
+        results.push(new EpsilonSegment());
+      } else {
+        results.push(new StaticSegment(segment));
+        types.statics++;
+      }
+    }
+
+    return results;
+  }
+
+  // A State has a character specification and (`charSpec`) and a list of possible
+  // subsequent states (`nextStates`).
+  //
+  // If a State is an accepting state, it will also have several additional
+  // properties:
+  //
+  // * `regex`: A regular expression that is used to extract parameters from paths
+  //   that reached this accepting state.
+  // * `handlers`: Information on how to convert the list of captures into calls
+  //   to registered handlers with the specified parameters
+  // * `types`: How many static, dynamic or star segments in this route. Used to
+  //   decide which route to use if multiple registered routes match a path.
+  //
+  // Currently, State is implemented naively by looping over `nextStates` and
+  // comparing a character specification against a character. A more efficient
+  // implementation would use a hash of keys pointing at one or more next states.
+
+  function State(charSpec) {
+    this.charSpec = charSpec;
+    this.nextStates = [];
+  }
+
+  State.prototype = {
+    get: function(charSpec) {
+      var nextStates = this.nextStates;
+
+      for (var i=0, l=nextStates.length; i<l; i++) {
+        var child = nextStates[i];
+
+        var isEqual = child.charSpec.validChars === charSpec.validChars;
+        isEqual = isEqual && child.charSpec.invalidChars === charSpec.invalidChars;
+
+        if (isEqual) { return child; }
+      }
+    },
+
+    put: function(charSpec) {
+      var state;
+
+      // If the character specification already exists in a child of the current
+      // state, just return that state.
+      if (state = this.get(charSpec)) { return state; }
+
+      // Make a new state for the character spec
+      state = new State(charSpec);
+
+      // Insert the new state as a child of the current state
+      this.nextStates.push(state);
+
+      // If this character specification repeats, insert the new state as a child
+      // of itself. Note that this will not trigger an infinite loop because each
+      // transition during recognition consumes a character.
+      if (charSpec.repeat) {
+        state.nextStates.push(state);
+      }
+
+      // Return the new state
+      return state;
+    },
+
+    // Find a list of child states matching the next character
+    match: function(ch) {
+      // DEBUG "Processing `" + ch + "`:"
+      var nextStates = this.nextStates,
+          child, charSpec, chars;
+
+      // DEBUG "  " + debugState(this)
+      var returned = [];
+
+      for (var i=0, l=nextStates.length; i<l; i++) {
+        child = nextStates[i];
+
+        charSpec = child.charSpec;
+
+        if (typeof (chars = charSpec.validChars) !== 'undefined') {
+          if (chars.indexOf(ch) !== -1) { returned.push(child); }
+        } else if (typeof (chars = charSpec.invalidChars) !== 'undefined') {
+          if (chars.indexOf(ch) === -1) { returned.push(child); }
+        }
+      }
+
+      return returned;
+    }
+
+    /** IF DEBUG
+    , debug: function() {
+      var charSpec = this.charSpec,
+          debug = "[",
+          chars = charSpec.validChars || charSpec.invalidChars;
+
+      if (charSpec.invalidChars) { debug += "^"; }
+      debug += chars;
+      debug += "]";
+
+      if (charSpec.repeat) { debug += "+"; }
+
+      return debug;
+    }
+    END IF **/
+  };
+
+  /** IF DEBUG
+  function debug(log) {
+    console.log(log);
+  }
+
+  function debugState(state) {
+    return state.nextStates.map(function(n) {
+      if (n.nextStates.length === 0) { return "( " + n.debug() + " [accepting] )"; }
+      return "( " + n.debug() + " <then> " + n.nextStates.map(function(s) { return s.debug() }).join(" or ") + " )";
+    }).join(", ")
+  }
+  END IF **/
+
+  // This is a somewhat naive strategy, but should work in a lot of cases
+  // A better strategy would properly resolve /posts/:id/new and /posts/edit/:id.
+  //
+  // This strategy generally prefers more static and less dynamic matching.
+  // Specifically, it
+  //
+  //  * prefers fewer stars to more, then
+  //  * prefers using stars for less of the match to more, then
+  //  * prefers fewer dynamic segments to more, then
+  //  * prefers more static segments to more
+  function sortSolutions(states) {
+    return states.sort(function(a, b) {
+      if (a.types.stars !== b.types.stars) { return a.types.stars - b.types.stars; }
+
+      if (a.types.stars) {
+        if (a.types.statics !== b.types.statics) { return b.types.statics - a.types.statics; }
+        if (a.types.dynamics !== b.types.dynamics) { return b.types.dynamics - a.types.dynamics; }
+      }
+
+      if (a.types.dynamics !== b.types.dynamics) { return a.types.dynamics - b.types.dynamics; }
+      if (a.types.statics !== b.types.statics) { return b.types.statics - a.types.statics; }
+
+      return 0;
+    });
+  }
+
+  function recognizeChar(states, ch) {
+    var nextStates = [];
+
+    for (var i=0, l=states.length; i<l; i++) {
+      var state = states[i];
+
+      nextStates = nextStates.concat(state.match(ch));
+    }
+
+    return nextStates;
+  }
+
+  var oCreate = Object.create || function(proto) {
+    function F() {}
+    F.prototype = proto;
+    return new F();
+  };
+
+  function RecognizeResults(queryParams) {
+    this.queryParams = queryParams || {};
+  }
+  RecognizeResults.prototype = oCreate({
+    splice: Array.prototype.splice,
+    slice:  Array.prototype.slice,
+    push:   Array.prototype.push,
+    length: 0,
+    queryParams: null
+  });
+
+  function findHandler(state, path, queryParams) {
+    var handlers = state.handlers, regex = state.regex;
+    var captures = path.match(regex), currentCapture = 1;
+    var result = new RecognizeResults(queryParams);
+
+    for (var i=0, l=handlers.length; i<l; i++) {
+      var handler = handlers[i], names = handler.names, params = {};
+
+      for (var j=0, m=names.length; j<m; j++) {
+        params[names[j]] = captures[currentCapture++];
+      }
+
+      result.push({ handler: handler.handler, params: params, isDynamic: !!names.length });
+    }
+
+    return result;
+  }
+
+  function addSegment(currentState, segment) {
+    segment.eachChar(function(ch) {
+      var state;
+
+      currentState = currentState.put(ch);
+    });
+
+    return currentState;
+  }
+
+  // The main interface
+
+  var RouteRecognizer = function() {
+    this.rootState = new State();
+    this.names = {};
+  };
+
+
+  RouteRecognizer.prototype = {
+    add: function(routes, options) {
+      var currentState = this.rootState, regex = "^",
+          types = { statics: 0, dynamics: 0, stars: 0 },
+          handlers = [], allSegments = [], name;
+
+      var isEmpty = true;
+
+      for (var i=0, l=routes.length; i<l; i++) {
+        var route = routes[i], names = [];
+
+        var segments = parse(route.path, names, types);
+
+        allSegments = allSegments.concat(segments);
+
+        for (var j=0, m=segments.length; j<m; j++) {
+          var segment = segments[j];
+
+          if (segment instanceof EpsilonSegment) { continue; }
+
+          isEmpty = false;
+
+          // Add a "/" for the new segment
+          currentState = currentState.put({ validChars: "/" });
+          regex += "/";
+
+          // Add a representation of the segment to the NFA and regex
+          currentState = addSegment(currentState, segment);
+          regex += segment.regex();
+        }
+
+        var handler = { handler: route.handler, names: names };
+        handlers.push(handler);
+      }
+
+      if (isEmpty) {
+        currentState = currentState.put({ validChars: "/" });
+        regex += "/";
+      }
+
+      currentState.handlers = handlers;
+      currentState.regex = new RegExp(regex + "$");
+      currentState.types = types;
+
+      if (name = options && options.as) {
+        this.names[name] = {
+          segments: allSegments,
+          handlers: handlers
+        };
+      }
+    },
+
+    handlersFor: function(name) {
+      var route = this.names[name], result = [];
+      if (!route) { throw new Error("There is no route named " + name); }
+
+      for (var i=0, l=route.handlers.length; i<l; i++) {
+        result.push(route.handlers[i]);
+      }
+
+      return result;
+    },
+
+    hasRoute: function(name) {
+      return !!this.names[name];
+    },
+
+    generate: function(name, params) {
+      var route = this.names[name], output = "";
+      if (!route) { throw new Error("There is no route named " + name); }
+
+      var segments = route.segments;
+
+      for (var i=0, l=segments.length; i<l; i++) {
+        var segment = segments[i];
+
+        if (segment instanceof EpsilonSegment) { continue; }
+
+        output += "/";
+        output += segment.generate(params);
+      }
+
+      if (output.charAt(0) !== '/') { output = '/' + output; }
+
+      if (params && params.queryParams) {
+        output += this.generateQueryString(params.queryParams, route.handlers);
+      }
+
+      return output;
+    },
+
+    generateQueryString: function(params, handlers) {
+      var pairs = [];
+      var keys = [];
+      for(var key in params) {
+        if (params.hasOwnProperty(key)) {
+          keys.push(key);
+        }
+      }
+      keys.sort();
+      for (var i = 0, len = keys.length; i < len; i++) {
+        key = keys[i];
+        var value = params[key];
+        if (value == null) {
+          continue;
+        }
+        var pair = key;
+        if (isArray(value)) {
+          for (var j = 0, l = value.length; j < l; j++) {
+            var arrayPair = key + '[]' + '=' + encodeURIComponent(value[j]);
+            pairs.push(arrayPair);
+          }
+        } else {
+          pair += "=" + encodeURIComponent(value);
+          pairs.push(pair);
+        }
+      }
+
+      if (pairs.length === 0) { return ''; }
+
+      return "?" + pairs.join("&");
+    },
+
+    parseQueryString: function(queryString) {
+      var pairs = queryString.split("&"), queryParams = {};
+      for(var i=0; i < pairs.length; i++) {
+        var pair      = pairs[i].split('='),
+            key       = decodeURIComponent(pair[0]),
+            keyLength = key.length,
+            isArray = false,
+            value;
+        if (pair.length === 1) {
+          value = 'true';
+        } else {
+          //Handle arrays
+          if (keyLength > 2 && key.slice(keyLength -2) === '[]') {
+            isArray = true;
+            key = key.slice(0, keyLength - 2);
+            if(!queryParams[key]) {
+              queryParams[key] = [];
+            }
+          }
+          value = pair[1] ? decodeURIComponent(pair[1]) : '';
+        }
+        if (isArray) {
+          queryParams[key].push(value);
+        } else {
+          queryParams[key] = decodeURIComponent(value);
+        }
+      }
+      return queryParams;
+    },
+
+    recognize: function(path) {
+      var states = [ this.rootState ],
+          pathLen, i, l, queryStart, queryParams = {},
+          isSlashDropped = false;
+
+      path = decodeURI(path);
+
+      queryStart = path.indexOf('?');
+      if (queryStart !== -1) {
+        var queryString = path.substr(queryStart + 1, path.length);
+        path = path.substr(0, queryStart);
+        queryParams = this.parseQueryString(queryString);
+      }
+
+      // DEBUG GROUP path
+
+      if (path.charAt(0) !== "/") { path = "/" + path; }
+
+      pathLen = path.length;
+      if (pathLen > 1 && path.charAt(pathLen - 1) === "/") {
+        path = path.substr(0, pathLen - 1);
+        isSlashDropped = true;
+      }
+
+      for (i=0, l=path.length; i<l; i++) {
+        states = recognizeChar(states, path.charAt(i));
+        if (!states.length) { break; }
+      }
+
+      // END DEBUG GROUP
+
+      var solutions = [];
+      for (i=0, l=states.length; i<l; i++) {
+        if (states[i].handlers) { solutions.push(states[i]); }
+      }
+
+      states = sortSolutions(solutions);
+
+      var state = solutions[0];
+
+      if (state && state.handlers) {
+        // if a trailing slash was dropped and a star segment is the last segment
+        // specified, put the trailing slash back
+        if (isSlashDropped && state.regex.source.slice(-5) === "(.+)$") {
+          path = path + "/";
+        }
+        return findHandler(state, path, queryParams);
+      }
+    }
+  };
+
+  __exports__.RouteRecognizer = RouteRecognizer;
+
+  function Target(path, matcher, delegate) {
+    this.path = path;
+    this.matcher = matcher;
+    this.delegate = delegate;
+  }
+
+  Target.prototype = {
+    to: function(target, callback) {
+      var delegate = this.delegate;
+
+      if (delegate && delegate.willAddRoute) {
+        target = delegate.willAddRoute(this.matcher.target, target);
+      }
+
+      this.matcher.add(this.path, target);
+
+      if (callback) {
+        if (callback.length === 0) { throw new Error("You must have an argument in the function passed to `to`"); }
+        this.matcher.addChild(this.path, target, callback, this.delegate);
+      }
+      return this;
+    }
+  };
+
+  function Matcher(target) {
+    this.routes = {};
+    this.children = {};
+    this.target = target;
+  }
+
+  Matcher.prototype = {
+    add: function(path, handler) {
+      this.routes[path] = handler;
+    },
+
+    addChild: function(path, target, callback, delegate) {
+      var matcher = new Matcher(target);
+      this.children[path] = matcher;
+
+      var match = generateMatch(path, matcher, delegate);
+
+      if (delegate && delegate.contextEntered) {
+        delegate.contextEntered(target, match);
+      }
+
+      callback(match);
+    }
+  };
+
+  function generateMatch(startingPath, matcher, delegate) {
+    return function(path, nestedCallback) {
+      var fullPath = startingPath + path;
+
+      if (nestedCallback) {
+        nestedCallback(generateMatch(fullPath, matcher, delegate));
+      } else {
+        return new Target(startingPath + path, matcher, delegate);
+      }
+    };
+  }
+
+  function addRoute(routeArray, path, handler) {
+    var len = 0;
+    for (var i=0, l=routeArray.length; i<l; i++) {
+      len += routeArray[i].path.length;
+    }
+
+    path = path.substr(len);
+    var route = { path: path, handler: handler };
+    routeArray.push(route);
+  }
+
+  function eachRoute(baseRoute, matcher, callback, binding) {
+    var routes = matcher.routes;
+
+    for (var path in routes) {
+      if (routes.hasOwnProperty(path)) {
+        var routeArray = baseRoute.slice();
+        addRoute(routeArray, path, routes[path]);
+
+        if (matcher.children[path]) {
+          eachRoute(routeArray, matcher.children[path], callback, binding);
+        } else {
+          callback.call(binding, routeArray);
+        }
+      }
+    }
+  }
+
+  RouteRecognizer.prototype.map = function(callback, addRouteCallback) {
+    var matcher = new Matcher();
+
+    callback(generateMatch("", matcher, this.delegate));
+
+    eachRoute([], matcher, function(route) {
+      if (addRouteCallback) { addRouteCallback(this, route); }
+      else { this.add(route); }
+    }, this);
+  };
+})(window);

+ 517 - 0
public/javascripts/smoke.js

@@ -0,0 +1,517 @@
+/*
+	SMOKE.JS - 0.1.3
+	(c) 2011-2013 Jonathan Youngblood
+	demos / documentation: http://smoke-js.com/ 
+*/
+
+;(function(window, document) {
+
+	/*jslint browser: true, onevar: true, undef: true, nomen: false, eqeqeq: true, bitwise: true, regexp: true, newcap: true, immed: true */
+	
+	var smoke = {
+	  smoketimeout: [],
+	  init: false,
+	  zindex: 40000,
+	  i: 0,
+	
+		bodyload: function(id) {
+			var ff = document.createElement('div');
+					ff.setAttribute('id','smoke-out-'+id);
+					ff.className = 'smoke-base';
+					ff.style.zIndex = smoke.zindex;
+					smoke.zindex++;
+					document.body.appendChild(ff);
+		},
+	
+		newdialog: function() {
+			var newid = new Date().getTime();
+					newid = Math.random(1,99) + newid;	
+	
+			if (!smoke.init) {		
+		    smoke.listen(window,"load", function() {
+			    smoke.bodyload(newid);
+				});
+			} else {
+		    smoke.bodyload(newid);		
+			}
+	
+			return newid;
+		},
+	
+		forceload: function() {},
+	
+		build: function (e, f) {
+			smoke.i++;
+			
+			f.stack = smoke.i;
+	
+			e = e.replace(/\n/g,'<br />');
+			e = e.replace(/\r/g,'<br />');
+	
+			var prompt = '',
+			    ok = 'OK',
+			    cancel = 'Cancel',
+			    classname = '',
+			    buttons = '',
+			    box;
+	
+			if (f.type === 'prompt') {
+				prompt = 
+					'<div class="smoke-dialog-prompt">'+
+						'<input class="input" id="smoke-dialog-input-'+f.newid+'" type="text" ' + (f.params.value ? 'value="' + f.params.value + '"' : '') + ' />'+
+					'</div>';
+			}
+	
+			if (f.params.ok) {
+				ok = f.params.ok;
+			}
+			
+			if (f.params.cancel) {
+				cancel = f.params.cancel;
+			}
+			
+			if (f.params.classname) {
+				classname = f.params.classname;
+			}
+	
+			if (f.type !== 'signal') {
+				buttons = '<div class="smoke-dialog-buttons">';
+				if (f.type === 'alert') {
+					buttons +=
+						'<button class="btn btn-md btn-round" id="alert-ok-'+f.newid+'">'+ok+'</button>';
+				}
+				 else if (f.type === 'quiz') {
+	
+					if (f.params.button_1) {
+						buttons +=
+							'<button class="btn btn-md btn-round quiz-button" id="'+f.type+'-ok1-'+f.newid+'">'+f.params.button_1+'</button>';
+					}
+	
+					if (f.params.button_2) {
+						buttons +=
+							'<button class="btn btn-md btn-round quiz-button" id="'+f.type+'-ok2-'+f.newid+'">'+f.params.button_2+'</button>';
+					}
+	
+					if (f.params.button_3) {
+						buttons +=
+							'<button class="btn btn-md btn-round quiz-button" id="'+f.type+'-ok3-'+f.newid+'">'+f.params.button_3+'</button>';
+					}
+					if (f.params.button_cancel) {
+						buttons +=
+							'<button id="'+f.type+'-cancel-'+f.newid+'" class="btn btn-md btn-round cancel">'+f.params.button_cancel+'</button>';
+					}
+	
+				}
+				
+				 else if (f.type === 'prompt' || f.type === 'confirm') {
+					if (f.params.reverseButtons) {
+						buttons +=
+							'<button class="btn btn-md btn-round btn-primary" id="'+f.type+'-ok-'+f.newid+'">'+ok+'</button>' +
+							'<button class="btn btn-md btn-round cancel" id="'+f.type+'-cancel-'+f.newid+'">'+cancel+'</button>';				
+					} else {
+						buttons +=
+							'<button class="btn btn-md btn-round cancel" id="'+f.type+'-cancel-'+f.newid+'">'+cancel+'</button>'+
+							'<button class="btn btn-md btn-round btn-primary" id="'+f.type+'-ok-'+f.newid+'">'+ok+'</button>';
+					}
+				}
+				buttons += '</div>';
+			}
+	
+	
+			box = 
+				'<div class="smoke-dialog smoke '+classname+'">'+
+					'<div class="smoke-dialog-inner">'+
+							e+
+							prompt+
+							buttons+			
+					'</div>'+
+				'</div>';
+	
+			if (!smoke.init) {		
+				smoke.listen(window,"load", function() {
+					smoke.finishbuild(e,f,box);
+				});
+			} else{
+				smoke.finishbuild(e,f,box);
+			}
+	
+		},
+	
+		finishbuild: function(e, f, box) {
+		
+			var ff = document.getElementById('smoke-out-'+f.newid);
+	
+			ff.className = 'smoke-base smoke-visible  smoke-' + f.type;
+			ff.innerHTML = box;
+					
+			while (ff.innerHTML === "") {
+				ff.innerHTML = box;
+			}
+			
+			if (smoke.smoketimeout[f.newid]) {
+				clearTimeout(smoke.smoketimeout[f.newid]);
+			}
+	
+			// smoke.listen(
+			// 	document.getElementById('smoke-bg-'+f.newid),
+			// 	"click", 
+			// 	function () {
+			// 		smoke.destroy(f.type, f.newid);
+			// 		if (f.type === 'prompt' || f.type === 'confirm' || f.type === 'quiz') {
+			// 			f.callback(false);
+			// 		} else if (f.type === 'alert' && typeof f.callback !== 'undefined') {
+			// 			f.callback();
+			// 		}	
+			// 	}
+			// );
+		
+		
+			switch (f.type) {
+				case 'alert': 
+					smoke.finishbuildAlert(e, f, box);
+					break;
+				case 'confirm':
+					smoke.finishbuildConfirm(e, f, box);
+					break;
+				case 'quiz':
+					smoke.finishbuildQuiz(e, f, box);
+					break;
+				case 'prompt':
+					smoke.finishbuildPrompt(e, f, box);
+					break;
+				case 'signal':
+					smoke.finishbuildSignal(e, f, box);
+					break;
+				default:
+					throw "Unknown type: " + f.type;
+			}
+		},
+		
+		finishbuildAlert: function (e, f, box) {
+			smoke.listen(
+				document.getElementById('alert-ok-'+f.newid),
+				"click", 
+				function () {
+					smoke.destroy(f.type, f.newid);
+					if (typeof f.callback !== 'undefined') {
+						f.callback();
+					}
+				}
+			);
+		
+			document.onkeyup = function (e) {
+				if (!e) {
+					e = window.event;
+				}
+				if (e.keyCode === 13 || e.keyCode === 32 || e.keyCode === 27) {
+					smoke.destroy(f.type, f.newid);
+					if (typeof f.callback !== 'undefined') {
+						f.callback();
+					}					
+				}
+			};	
+		},
+		
+		finishbuildConfirm: function (e, f, box) {
+			smoke.listen(
+				document.getElementById('confirm-cancel-' + f.newid),
+				"click", 
+				function () 
+				{
+					smoke.destroy(f.type, f.newid);
+					f.callback(false);
+				}
+			);
+			
+			smoke.listen(
+				document.getElementById('confirm-ok-' + f.newid),
+				"click", 
+				function () 
+				{
+					smoke.destroy(f.type, f.newid);
+					f.callback(true);
+				}
+			);
+					
+			document.onkeyup = function (e) {
+				if (!e) {
+					e = window.event;
+				}
+				if (e.keyCode === 13 || e.keyCode === 32) {
+					smoke.destroy(f.type, f.newid);
+					f.callback(true);
+				} else if (e.keyCode === 27) {
+					smoke.destroy(f.type, f.newid);
+					f.callback(false);
+				}
+			};	
+		},
+		
+		finishbuildQuiz: function (e, f, box) {
+			var a, b, c;
+			
+			smoke.listen(
+				document.getElementById('quiz-cancel-' + f.newid),
+				"click", 
+				function () 
+				{
+					smoke.destroy(f.type, f.newid);
+					f.callback(false);
+				}
+			);
+	
+	
+			if (a = document.getElementById('quiz-ok1-'+f.newid))
+			smoke.listen(
+				a,
+				"click", 
+				function () {
+					smoke.destroy(f.type, f.newid);
+					f.callback(a.innerHTML);
+				}
+			);
+	
+	
+			if (b = document.getElementById('quiz-ok2-'+f.newid))
+			smoke.listen(
+				b,
+				"click", 
+				function () {
+					smoke.destroy(f.type, f.newid);
+					f.callback(b.innerHTML);
+				}
+			);
+	
+	
+			if (c = document.getElementById('quiz-ok3-'+f.newid))
+			smoke.listen(
+				c,
+				"click", 
+				function () {
+					smoke.destroy(f.type, f.newid);
+					f.callback(c.innerHTML);
+				}
+			);
+	
+			document.onkeyup = function (e) {
+				if (!e) {
+					e = window.event;
+				}
+				if (e.keyCode === 27) {
+					smoke.destroy(f.type, f.newid);
+					f.callback(false);
+				}
+			};	
+		
+		},
+		
+		finishbuildPrompt: function (e, f, box) {
+			var pi = document.getElementById('smoke-dialog-input-'+f.newid);
+				
+			setTimeout(function () {
+				pi.focus();
+				pi.select();
+			}, 100);
+		
+			smoke.listen(
+				document.getElementById('prompt-cancel-'+f.newid),
+				"click", 
+				function () {
+					smoke.destroy(f.type, f.newid);
+					f.callback(false);
+				}
+			);
+		
+			smoke.listen(
+				document.getElementById('prompt-ok-'+f.newid),
+				"click", 
+				function () {
+					smoke.destroy(f.type, f.newid);
+					f.callback(pi.value);
+				}
+			);
+					
+			document.onkeyup = function (e) {
+				if (!e) {
+					e = window.event;
+				}
+				
+				if (e.keyCode === 13) {
+					smoke.destroy(f.type, f.newid);
+					f.callback(pi.value);
+				} else if (e.keyCode === 27) {
+					smoke.destroy(f.type, f.newid);
+					f.callback(false);
+				}
+			};
+		},
+		
+		finishbuildSignal: function (e, f, box) {
+	
+	
+			document.onkeyup = function (e) {
+				if (!e) {
+					e = window.event;
+				}
+				if (e.keyCode === 27) {
+					smoke.destroy(f.type, f.newid);
+					if (typeof f.callback !== 'undefined') {
+						f.callback();
+					}
+				}
+			};	
+	
+			smoke.smoketimeout[f.newid] = setTimeout(function () {
+				smoke.destroy(f.type, f.newid);
+				if (typeof f.callback !== 'undefined') {
+					f.callback();
+				}
+			}, f.timeout);
+		},
+		
+				
+		destroy: function (type,id) {
+	
+			var box = document.getElementById('smoke-out-'+id);
+	
+			if (type !== 'quiz') {
+			    var okButton = document.getElementById(type+'-ok-'+id);
+			}
+	
+	    var cancelButton = document.getElementById(type+'-cancel-'+id);
+			box.className = 'smoke-base';
+	
+			if (okButton) {
+				smoke.stoplistening(okButton, "click", function() {});
+				document.onkeyup = null;
+			}
+			
+			if (type === 'quiz') {
+				var quiz_buttons = document.getElementsByClassName("quiz-button");
+				for (var i = 0; i < quiz_buttons.length; i++) {
+				smoke.stoplistening(quiz_buttons[i], "click", function() {});
+				document.onkeyup = null;
+				}			
+			}
+			
+			if (cancelButton) {
+				smoke.stoplistening(cancelButton, "click", function() {});
+			}
+			
+			smoke.i = 0;
+			box.innerHTML = '';
+		},
+	
+		alert: function (e, f, g) {
+			if (typeof g !== 'object') {
+				g = false;
+			}
+			
+			var id = smoke.newdialog();
+			
+			smoke.build(e, {
+				type:     'alert',
+				callback: f,
+				params:   g,
+				newid:    id
+			});
+		},
+		
+		signal: function (e, f, g) {
+			if (typeof g !== 'object') {
+				g = false;
+			}		
+
+			var duration = 5000;
+			if (g.duration !== 'undefined'){
+				duration = g.duration;
+			}
+			
+			var id = smoke.newdialog();
+			smoke.build(e, {
+				type:    'signal',
+				callback: f,
+				timeout: duration,
+				params:  g,
+				newid:   id
+			});
+		},
+		
+		confirm: function (e, f, g) {
+			if (typeof g !== 'object') {
+				g = false;
+			}
+			
+			var id = smoke.newdialog();
+			smoke.build(e, {
+				type:     'confirm',
+				callback: f,
+				params:   g,
+				newid:    id
+			});
+		},
+		
+		quiz: function (e, f, g) {
+			if (typeof g !== 'object') {
+				g = false;
+			}
+			
+			var id = smoke.newdialog();
+			smoke.build(e, {
+				type:     'quiz',
+				callback: f,
+				params:   g,
+				newid:    id
+			});
+		},
+		
+		prompt: function (e, f, g) {
+			if (typeof g !== 'object') {
+				g = false;
+			}
+			
+			var id = smoke.newdialog();
+			return smoke.build(e,{type:'prompt',callback:f,params:g,newid:id});
+		},
+		
+		listen: function (e, f, g) {
+	    if (e.addEventListener) {
+	      return e.addEventListener(f, g, false);
+	    } 
+	    
+	    if (e.attachEvent) {
+	      return e.attachEvent('on'+f, g);
+	    } 
+	    
+			return false;
+		},
+		
+		stoplistening: function (e, f, g) {	
+	    if (e.removeEventListener) {
+	      return e.removeEventListener(f, g, false);
+	    }
+	    
+	    if (e.detachEvent) {
+	      return e.detachEvent('on'+f, g);
+	    }
+	    
+	    return false;
+		}
+	};
+	
+	
+	smoke.init = true;
+
+	if (typeof module != 'undefined' && module.exports) {
+		module.exports = smoke;
+	}
+	else if (typeof define === 'function' && define.amd) {
+		define('smoke', [], function() {
+		    return smoke;
+		});
+	}
+	else {
+		this.smoke = smoke;
+	}
+
+})(window, document);

+ 127 - 0
public/javascripts/spacedeck_account.js

@@ -0,0 +1,127 @@
+/*
+  SpacedeckAccount
+  This module contains functions dealing with the spacedeck account.
+*/
+
+SpacedeckAccount = {
+  data: {
+    account_confirmed_sent: false,
+    account_tab: 'invoices',
+    password_change_error: null,
+    feedback_text: "",
+    importables: [], // spacedeck.com zip import files
+  },
+  methods: {
+    show_account: function() {
+      this.activate_dropdown('account');
+    },
+
+    start_zip_import: function(f) {
+      if (confirm("Your archive will be imported in the background. This can take a few minutes. You can continue using Spacedeck in the meantime.")) {
+        import_zip(this.user, f);
+      }
+    },
+
+    account_save_user_digest: function(val) {
+      this.user.prefs_email_digest = val;
+      this.save_user(function() {  
+      });
+    },
+
+    account_save_user_notifications: function(val) {
+      this.user.prefs_email_notifications = val;
+      this.save_user(function() {
+      });
+    },
+
+    save_user_email: function() {
+      this.save_user(function() {
+      }.bind(this));
+    },
+
+    save_user_language: function(lang) {
+      localStorage.lang = lang;
+      this.user.prefs_language = lang;
+      this.save_user(function() {
+        window._spacedeck_location_change = true;
+        location.href="/spaces";
+      }.bind(this));
+    },
+
+    save_user: function(on_success) {
+      if (this.user.email_changed) {
+        this.user.confirmed_at = null;
+      }
+      window._spacedeck_location_change = true;
+
+      save_user(this.user, function(user) {
+        if (on_success) on_success();
+        else location.href="/spaces";
+
+      }.bind(this), function(xhr){
+        console.error(xhr)
+      });
+    },
+
+    save_user_password: function(oldPass, newPass, newPassConfirm) {
+      this.password_change_error = null;
+
+      if (!oldPass) {
+        this.password_change_error = "Current password required";
+        return;
+      }
+
+      if (!newPass || !newPassConfirm) {
+        this.password_change_error = "New password/password confirmation required";
+        return;
+      }
+
+      if (newPass!=newPassConfirm) {
+        this.password_change_error = "New Passwords do not match";
+        return;
+      }
+
+      if (newPass.length < 6) {
+        this.password_change_error = "New Password to short";
+        return;
+      }
+
+      save_user_password(this.user, oldPass, newPass, function() {
+        alert("OK. Password Changed.");
+        this.password_change_current = "";
+        this.password_change_new = "";
+        this.password_change_new_confirmation = "";
+      }.bind(this), function(xhr) {
+        if (xhr.status == 403) {
+          this.password_change_error = "Old Password not correct";
+        } else {
+          this.password_change_error = "Something went wrong. Please try again later.";
+        }
+      }.bind(this));
+    },
+
+    confirm_again: function() {
+      resent_confirm_mail(this.user, function(re) {
+        this.account_confirmed_sent = true;
+
+        alert(__("confirm_again"));
+
+      }.bind(this), function(xhr){
+        console.error(xhr);
+        alert("Something went wrong, please try again.");
+      });
+    },
+
+    confirm_account: function(token) {
+      confirm_user(this.user, token, function(re) {
+        smoke.alert(__("confirmed"), function() {
+          this.redirect_to("/spaces");
+        }.bind(this));
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+        alert(xhr.responseText);
+        this.redirect_to("/spaces");
+      }.bind(this));
+    },
+  }
+}

+ 81 - 0
public/javascripts/spacedeck_avatars.js

@@ -0,0 +1,81 @@
+
+var SpacedeckAvatars = {
+  data: {
+    uploading_avatar: false,
+    uploading_folder_avatar: false,
+    uploading_cover: false
+  },
+
+  methods: {
+    save_avatar_image: function(input, object_type, object) {
+      if (input.files.length > 0) {
+        var f = input.files[0];
+
+        var finished = function() {
+          this.uploading_avatar = false;
+          this.uploading_cover = false;
+          this.uploading_folder_avatar = false;
+        }.bind(this);
+
+        if (!_.include(["image/jpeg","image/jpg","image/png","image/gif"], f.type)) {
+          alert("Unsupported file type. Please upload JPEG, PNG or GIF.");
+          finished();
+          return;
+        }
+
+        if (f.size > 1024*1024*3) {
+          alert("File must be smaller than 3 megabytes.");
+          finished();
+          return;
+        }
+
+        save_avatar_file(object_type, object, f, function(res) {
+          finished();
+          this.uploading_avatar = false;
+          this.uploading_cover = false;
+
+          var newUri = res.avatar_thumb_uri;
+          object.avatar_thumb_uri = newUri + "?cachebuster=" + Math.random();
+        }.bind(this), function(error) {
+          alert("Upload failed: " + error);
+          finished();
+        });
+      }
+    },
+
+    save_space_avatar_image: function(viewmodel) {
+      this.uploading_avatar = true;
+      var func = this.save_avatar_image.bind(this);
+      func(viewmodel.$event.target, "space", this.active_space);
+    },
+    save_folder_avatar_image: function(viewmodel) {
+      this.uploading_folder_avatar = true;
+      var func = this.save_avatar_image.bind(this);
+      func(viewmodel.$event.target, "space", this.active_folder);
+    },
+    save_user_avatar_image: function(viewmodel) {
+      this.uploading_avatar = true;
+      var func = this.save_avatar_image.bind(this);
+      func(viewmodel.$event.target, "user", viewmodel.$root.user);
+    },
+
+    delete_user_avatar_image: function() {
+      this.user.avatar_original_uri = "";
+      this.user.avatar_thumb_uri = "";
+      save_user(this.user,function(updated) {
+      }.bind(this));
+    },
+
+    save_user_background_image: function(viewmodel) {
+      var input = viewmodel.$event.target;
+
+      this.uploading_cover = true;
+
+      var f = input.files[0];
+      save_user_background_file(this.user, f, function(res) {
+        this.user.background_original_uri = res.background_original_uri;
+        this.uploading_cover = false;
+      }.bind(this));
+    }
+  }
+}

+ 665 - 0
public/javascripts/spacedeck_board_artifacts.js

@@ -0,0 +1,665 @@
+/*
+  SpacedeckBoardArtifacts
+  This module contains functions dealing with absolute positioned Board Section Artifacts.
+*/
+
+var SpacedeckBoardArtifacts = {
+  update_board_artifact_viewmodel: function(a) {
+    var mt = this.artifact_major_type(a);
+
+    a.view = {
+      _id:                  a._id,
+      classes:              this.artifact_classes(a),
+      style:                this.artifact_style(a),
+      grid_style:           this.artifact_style(a, true),
+      inner_style:          this.artifact_inner_style(a),
+      text_cell_style:      this.artifact_text_cell_style(a),
+      vector_svg:           this.artifact_vector_svg(a),
+      payload_uri:          a.payload_uri,
+      thumbnail_uri:        this.artifact_thumbnail_uri(a),
+      major_type:           mt,
+      text_blank:           this.artifact_is_text_blank(a),
+      payload_alternatives: a.payload_alternatives,
+      filename:             this.artifact_filename(a),
+      oembed_html:          this.artifact_oembed_html(a),
+      link:                 this.artifact_link(a),
+      link_caption:         this.artifact_link_caption(a),
+      interactive:          0
+    };
+
+    if ((mt == "audio" || mt == "video") && !a.player_view) {
+      a.player_view = {
+        state: "stop",
+        current_time_string: "",
+        total_time_string: "",
+        current_time_float: 0.0,
+        inpoint_float: 0.0,
+        outpoint_float: 0.0
+      };
+    }
+
+    if ("medium_for_object" in this) {
+      var medium = this.medium_for_object[a._id];
+      if (medium && a._id != this.editing_artifact_id) {
+        medium.value(a.description.toString());
+      }
+    }
+  },
+
+  is_artifact_audio: function(a) {
+    if (a) {
+      return a.mime.match("audio");
+    } else return false;
+  },
+
+  artifact_filename: function(a) {
+    if (a.payload_uri) {
+      return a.payload_uri.replace(/.*\//g,"");
+    } else {
+      return "";
+    }
+  },
+
+  artifact_link: function(a) {
+    if (a.link_uri) {
+      return a.link_uri;
+    } else {
+      return "";
+    }
+  },
+
+  artifact_link_caption: function(a) {
+    if (a.link_uri) {
+      var parts = a.link_uri.split("/");
+      // scheme://domain.foo/...
+      // 0      1 2
+      if (parts.length>2) {
+        return parts[2];
+      }
+      return "Link";
+    } else {
+      return "";
+    }
+  },
+
+  artifact_is_selected: function(a) {
+    if (!a) return false;
+    return !!this.selected_artifacts_dict[a._id];
+  },
+
+  artifact_is_text_blank: function(a) {
+    if (a.description) {
+      desc = a.description.toString();
+      var filtered = desc.replace(/<[^>]+>/g,"").replace(/\s/g,"");
+      return (filtered.length<1);
+    } else {
+      return false;
+    }
+  },
+
+  artifact_classes: function(a) {
+    clzs = ["artifact", "artifact-"+this.artifact_major_type(a), a.mime.replace("/","-")];
+
+    if (this.artifact_is_selected(a) && this.editing_artifact_id!=a._id) clzs.push("selected");
+    if (!a._id) clzs.push("creating");
+
+    if (a.align) clzs.push("align-"+a.align);
+    if (a.valign) clzs.push("align-"+a.valign);
+    
+    clzs.push("state-"+a.state);
+
+    if (this.artifact_is_text_blank(a)) {
+      clzs.push("text-blank");
+    }
+
+    if (a.locked) {
+      clzs.push("locked");
+    }
+    
+    return clzs.join(" ");
+  },
+
+  artifact_inner_style: function(a) {
+    var styles = [];
+
+    //if (a.style) {
+
+      var svg_style = ((a.mime.match("vector") || a.mime.match("shape")) && a.shape!="square");
+
+      if (!svg_style) {
+        if (a.stroke) {
+          styles.push("border-width:"+a.stroke+"px");
+          styles.push("border-style:"+(a.stroke_style||"solid"));
+        }
+        if (a.stroke_color) {
+          styles.push("border-color:"+a.stroke_color);
+        }
+        if (a.border_radius) {
+          styles.push("border-radius:"+a.border_radius+"px");
+        }
+      }
+
+      if (a.fill_color && !svg_style) {
+        styles.push("background-color:"+a.fill_color);
+      }
+      if (a.text_color) {
+        styles.push("color:"+a.text_color);
+      }
+
+      var filters = [];
+
+      if (!isNaN(a.brightness) && a.brightness != 100) {
+        filters.push("brightness("+a.brightness+"%)");
+      }
+      if (!isNaN(a.contrast) && a.contrast != 100) {
+        filters.push("contrast("+a.contrast+"%)");
+      }
+      if (!isNaN(a.opacity) && a.opacity != 100) {
+        filters.push("opacity("+a.opacity+"%)");
+      }
+      if (!isNaN(a.hue) && a.hue) {
+        filters.push("hue-rotate("+a.hue+"deg)");
+      }
+      if (!isNaN(a.saturation) && a.saturation != 100) {
+        filters.push("saturate("+a.saturation+"%)");
+      }
+      if (!isNaN(a.blur) && a.blur) {
+        filters.push("blur("+a.blur+"px)");
+      }
+
+      if (filters.length) {
+        styles.push("-webkit-filter:"+filters.join(" "));
+        styles.push("filter:"+filters.join(" "));
+      }
+    //}
+
+    return styles.join(";");
+  },
+
+  artifact_text_cell_style: function(a, for_text_editor) {
+    var styles = [];
+
+    if (a.padding_left)   styles.push("padding-left:"+a.padding_left+"px");
+    if (a.padding_right)  styles.push("padding-right:"+a.padding_right+"px");
+    if (a.padding_top)    styles.push("padding-top:"+a.padding_top+"px");
+    if (a.padding_bottom) styles.push("padding-bottom:"+a.padding_bottom+"px");
+
+    return styles.join(";");
+  },
+
+  artifact_style: function(a, for_grid) {
+    var styles = [];
+    var z = 0;
+
+    z = a.z;
+    if (z<0) z=0; // fix negative z-index
+    
+    styles = [
+      "left:"  +a.x+"px",
+      "top:"   +a.y+"px",
+      "width:" +a.w+"px",
+      "height:"+a.h+"px",
+      "z-index:"+z
+    ];
+
+    if (a.margin_left)   styles.push("margin-left:"+a.margin_left+"px");
+    if (a.margin_right)  styles.push("margin-right:"+a.margin_right+"px");
+    if (a.margin_top)    styles.push("margin-top:"+a.margin_top+"px");
+    if (a.margin_bottom) styles.push("margin-bottom:"+a.margin_bottom+"px");
+    
+    // FIXME: via class logic?
+    if (a.mime.match("vector")) {
+      styles.push("overflow:visible");
+    }
+
+    return styles.join(";");
+  },
+
+  artifact_major_type: function(a) {
+    if (a.mime.match("oembed")) return "oembed";
+    if (a.mime.match("zone")) return "zone";
+    if (a.mime.match("svg")) return "svg";
+    if (a.mime.match("image")) return "image";
+    if (a.mime.match("pdf")) return "image";
+    if (a.mime.match("video")) return "video";
+    if (a.mime.match("audio")) return "audio";
+    if (a.mime.match("website")) return "website";
+    if (a.mime.match("vector")) return "vector";
+    if (a.mime.match("shape")) return "shape";
+    if (a.mime.match("placeholder")) return "placeholder";
+    if (a.mime.match("text") || a.mime.match("note")) return "text";
+
+    return "file";
+  },
+
+  artifact_thumbnail_uri: function(a) {
+    if (a.payload_thumbnail_big_uri && a.board) {
+      if (a.w>800) {
+        return a.payload_thumbnail_big_uri;
+      }
+    }
+    return a.payload_thumbnail_medium_uri || a.payload_thumbnail_big_uri || a.payload_thumbnail_web_uri || "";
+  },
+
+  artifact_oembed_html: function(a) {
+    if (this.artifact_major_type(a) != "oembed") return "";
+
+    var parts = a.mime.split("/")[1].split("-");
+    var type = parts[0];
+    var provider = parts[1];
+
+    if (!a.link_uri) {
+      console.log("missing meta / link_uri: ",a);
+      console.log("type/provider: ",type,provider);
+      return ("missing metadata: "+a._id);
+    }
+
+    if (provider=="youtube") {
+      var vid = a.link_uri.match(/(v=|\/)([a-zA-Z0-9\-_]{11})/);
+      if (vid && vid.length>2) {
+        var uri = "https://youtube.com/embed/"+vid[2];
+        return "<iframe frameborder=0 allowfullscreen src=\""+uri+"?showinfo=0&rel=0&controls=0\"></iframe>";
+      } else return "Can't resolve: "+a.payload_uri;
+
+    } else if (provider=="dailymotion") {
+      var match = a.link_uri.match(/dailymotion.com\/video\/([^<]*)/);
+      if (match && match.length>1) {
+        var uri = "https://www.dailymotion.com/embed/video/"+match[1];
+        return "<iframe frameborder=0 allowfullscreen src=\""+uri+"\"></iframe>";
+      } else return "Can't resolve: "+a.payload_uri;
+
+    } else if (provider=="vimeo") {
+      var match = a.link_uri.match(/https?:\/\/(www\.)?vimeo.com\/(\d+)($|\/)/);
+      if (match) {
+        var uri = "https://player.vimeo.com/video/"+match[2];
+        return "<iframe frameborder=0 allowfullscreen src=\""+uri+"\"></iframe>";
+      } else return "Can't resolve: "+a.payload_uri;
+
+    } else if (provider=="soundcloud") {
+      return '<iframe width="100%" height="166" scrolling="no" frameborder="no" src="https://w.soundcloud.com/player/?url='+a.link_uri.replace(":", "%3A")+'"></iframe>';
+
+    } else if (provider=="spacedeck") {
+
+      return ""; //<iframe frameborder=0 allowfullscreen src=\""+ a.meta.link_uri+"\"></iframe>
+
+    } else {
+      return "Don't know how to embed "+a.mime+".";
+    }
+  },
+
+  artifact_vector_svg: function(a) {
+    var mtype = this.artifact_major_type(a);
+
+    if (mtype != "vector" && mtype != "shape") return "";
+
+    var shape = a.shape || "";
+    var padding = 32 + a.stroke*2;
+    var path_svg;
+    var fill = "";
+
+    if (mtype == "vector") {
+      path_svg = render_vector_drawing(a, padding);
+
+      fill = "fill:none";
+    } else {
+      path_svg = render_vector_shape(a, padding);
+      fill = "fill:"+a.fill_color+";";
+      padding = 0;
+    }
+    var margin = padding;
+
+    var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='"+(a.w+2*padding)+"' height='"+(a.h+2*padding)+"' ";
+    svg += "style='margin-left:"+(-margin)+"px;margin-top:"+(-margin)+"px;stroke-width:"+a.stroke+";stroke:"+a.stroke_color+";"+fill+"'>";
+    svg += path_svg;
+    svg += "</svg>";
+
+    return svg;
+  },
+
+  /* whiteboard layouting functions */
+
+  artifact_enclosing_rect: function(arts) {
+    if (arts.length==0) return null;
+
+    r = {
+      x1: parseInt(_.min(arts.map(function(a){return a.x}))),
+      y1: parseInt(_.min(arts.map(function(a){return a.y}))),
+      x2: parseInt(_.max(arts.map(function(a){return a.x+a.w}))),
+      y2: parseInt(_.max(arts.map(function(a){return a.y+a.h})))
+    };
+    r.x=r.x1;
+    r.y=r.y1;
+    r.w=r.x2-r.x1;
+    r.h=r.y2-r.y1;
+
+    return r;
+  },
+
+  artifact_selection_rect: function() {
+    return this.artifact_enclosing_rect(this.selected_artifacts());
+  },
+
+  rects_intersecting: function(r1,r2) {
+    if ( (r1.x+r1.w < r2.x)
+      || (r1.x > r2.x+r2.w)
+      || (r1.y+r1.h < r2.y)
+      || (r1.y > r2.y+r2.h) ) return false;
+    return true;
+  },
+
+  artifacts_in_rect: function(rect) {
+    return _.filter(this.active_space_artifacts, function(a) {
+      return this.rects_intersecting(a, rect);
+    }.bind(this));
+  },
+
+  layout_stack_top: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    var overlapping = _.filter(this.artifacts_in_rect(rect), function(a){return !this.is_selected(a)}.bind(this));
+
+    var max_z = _.max(overlapping,function(a){ return a.z; });
+    if (max_z.board) {
+      max_z = max_z.z + 1;
+    } else {
+      max_z = 1;
+    }
+    
+    this.update_selected_artifacts(function(a) {
+      return { z: max_z };
+    });
+  },
+
+  layout_stack_bottom: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    var overlapping = _.filter(this.artifacts_in_rect(rect), function(a){return !this.is_selected(a);}.bind(this));
+
+    var min_z = _.min(overlapping,function(a){ return a.z; });
+    if (min_z.board) {
+      min_z = min_z.z - 1;
+    } else {
+      min_z = 0;
+    }
+    var my_z = _.max(this.selected_artifacts(),function(a){ return a.z; });
+    if (my_z.board) {
+      my_z = my_z.z - 1;
+    } else {
+      my_z = 0;
+    }
+
+    // TODO: move all other items up in this case?
+    if (min_z < 0) {
+      this.update_artifacts(overlapping, function(a) {
+        return { z: (my_z + a.z + 1) };
+      });
+
+      return;
+    }
+
+    this.update_selected_artifacts(function(a) {
+      return { z: min_z };
+    });
+  },
+
+  layout_align_left:function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    this.update_selected_artifacts(function(a) {
+      return { x: rect.x1 };
+    });
+  },
+
+  layout_align_top: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    this.update_selected_artifacts(function(a) {
+      return { y: rect.y1 };
+    });
+  },
+
+  layout_align_right: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    this.update_selected_artifacts(function(a) {
+      return { x: rect.x2 - a.w };
+    });
+  },
+
+  layout_align_bottom: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    this.update_selected_artifacts(function(a) {
+      return { y: rect.y2 - a.h };
+    });
+  },
+
+  layout_align_center: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    var cx = rect.x1 + (rect.x2-rect.x1)/2;
+    this.update_selected_artifacts(function(a) {
+      return { x: cx - a.w/2 };
+    });
+  },
+
+  layout_align_middle: function() {
+    this.begin_transaction();
+
+    var rect = this.artifact_selection_rect();
+    var cy = rect.y1 + (rect.y2-rect.y1)/2;
+    this.update_selected_artifacts(function(a) {
+      return { y: cy - a.h/2 };
+    });
+  },
+
+  layout_match_size_horiz:function() {
+    this.begin_transaction();
+
+    var arts = this.selected_artifacts();
+    if (arts.length<2) return;
+
+    var totalw  = _.reduce(arts, function(sum, a) { return sum + a.w }, 0);
+    var avgw    = totalw / arts.length;
+
+    this.update_selected_artifacts(function(a) {
+      return { w: avgw };
+    });
+  },
+
+  layout_match_size_vert:function() {
+    this.begin_transaction();
+
+    var arts = this.selected_artifacts();
+    if (arts.length<2) return;
+
+    var totalh  = _.reduce(arts, function(sum, a) { return sum + a.h }, 0);
+    var avgh    = totalh / arts.length;
+
+    this.update_selected_artifacts(function(a) {
+      return { h: avgh };
+    });
+  },
+
+  layout_match_size_both:function() {
+    this.layout_match_size_horiz();
+    this.layout_match_size_vert();
+  },
+
+  layout_distribute_horizontal: function() {
+    this.begin_transaction();
+
+    var selected = this.selected_artifacts();
+    if (selected.length<3) return;
+
+    var sorted = _.sortBy(selected, function(a) { return a.x });
+    var startx = sorted[0].x + sorted[0].w/2;
+    var stopx  = _.last(sorted).x + _.last(sorted).w/2;
+    var step   = (stopx-startx)/(sorted.length-1);
+
+    for (var i=1; i<sorted.length-1; i++) {
+      var a = sorted[i];
+      var x = startx + step*i - a.w/2;
+      this.update_artifacts([a],function(a) {
+        return { x: x }
+      });
+    }
+  },
+
+  layout_distribute_vertical: function() {
+    this.begin_transaction();
+
+    var selected = this.selected_artifacts();
+    if (selected.length<3) return;
+
+    var sorted = _.sortBy(selected, function(a) { return a.y });
+    var starty = sorted[0].y + sorted[0].h/2;
+    var stopy  = _.last(sorted).y + _.last(sorted).h/2;
+    var step   = (stopy-starty)/(sorted.length-1);
+
+    for (var i=1; i<sorted.length-1; i++) {
+      var a = sorted[i];
+      var y = starty + step*i - a.h/2;
+      this.update_artifacts([a],function(a) {
+        return { y: y }
+      });
+    }
+  },
+
+  layout_distribute_horizontal_spacing: function() {
+    this.begin_transaction();
+
+    var selected = this.selected_artifacts();
+    if (selected.length<3) return;
+
+    var sorted  = _.sortBy(selected, function(a) { return a.x });
+    var startx  = sorted[0].x;
+    var stopx   = _.last(sorted).x + _.last(sorted).w;
+    var range   = stopx - startx;
+    var totalw  = _.reduce(sorted, function(sum, a) { return sum + a.w }, 0);
+    var avgs    = (range - totalw) / (sorted.length-1);
+    var prevend = startx + sorted[0].w;
+
+    for (var i=1; i<sorted.length-1; i++) {
+      var a = sorted[i];
+      var x = prevend + avgs;
+      this.update_artifacts([a],function(a) {
+        return { x: x }
+      });
+      prevend = x+a.w;
+    }
+  },
+
+  layout_distribute_vertical_spacing: function() {
+    this.begin_transaction();
+
+    var selected = this.selected_artifacts();
+    if (selected.length<3) return;
+
+    var sorted  = _.sortBy(selected, function(a) { return a.y });
+    var starty  = sorted[0].y;
+    var stopy   = _.last(sorted).y + _.last(sorted).h;
+    var range   = stopy - starty;
+    var totalh  = _.reduce(sorted, function(sum, a) { return sum + a.h }, 0);
+    var avgs    = (range - totalh) / (sorted.length-1);
+    var prevend = starty + sorted[0].h;
+
+    for (var i=1; i<sorted.length-1; i++) {
+      var a = sorted[i];
+      var y = prevend + avgs;
+      this.update_artifacts([a],function(a) {
+        return { y: y }
+      });
+      prevend = y+a.h;
+    }
+  },
+
+  layout_auto: function() {
+    this.begin_transaction();
+
+    var selected = this.selected_artifacts();
+    if (selected.length<2) return;
+
+    var sorted = _.sortBy(selected, function(a) { return a.x+a.y*this.active_space.width }.bind(this));
+
+    var minx = sorted[0].x;
+    var miny = sorted[0].y;
+
+    var sorted = _.sortBy(selected, function(a) { return -Math.max(a.w,a.h) }.bind(this));
+    
+    var blocks = [];
+
+    var padding = 10;
+
+    for (var i=0; i<sorted.length; i++) {
+      var a = sorted[i];
+      blocks.push({
+        w: a.w+padding,
+        h: a.h+padding,
+        a: a
+      });
+    }
+    
+    var packer = new GrowingPacker();
+    packer.fit(blocks);
+
+    for (var i=0; i<blocks.length; i++) {
+      var block = blocks[i];
+      if (block.fit) {
+        var a = block.a;
+        this.update_artifacts([a],function(a) {
+          return {
+            x: minx+block.fit.x,
+            y: miny+block.fit.y
+          }
+        });
+      }
+    }
+  },
+  
+  show_artifact_comments: function(evt) {
+    evt.preventDefault();
+    evt.stopPropagation();
+
+    var artifact = this.selected_artifacts()[0];
+
+    this.selected_artifact = artifact;
+    this.activate_modal('artifact');
+  },
+
+  create_artifact_comment: function(artifact, comment) {
+    var data = {
+      artifact_id: artifact._id,
+      space_id: this.active_space._id,
+      message: comment,
+      user: this.user
+    };
+
+    save_comment(this.active_space._id, data, function(comment) {
+      this.active_space_messages.push(comment);
+      this.artifact_comment = "";
+    }.bind(this), function(xhr){
+      console.error(xhr);
+    }.bind(this));
+  },
+
+  remove_artifact_comment: function(comment) {
+    delete_comment(this.active_space._id, comment._id, function(comment) {
+      this.active_space_messages.pop(comment);
+    }.bind(this), function(xhr){
+      console.error(xhr);
+    }.bind(this));
+  }
+
+}
+
+if (typeof(window) == 'undefined') {
+  exports.SpacedeckBoardArtifacts = SpacedeckBoardArtifacts;
+}

+ 568 - 0
public/javascripts/spacedeck_directives.js

@@ -0,0 +1,568 @@
+
+/*
+  Spacedeck Directives
+  This module registers custom Vue directives for Spacedeck.
+*/
+
+function setup_directives() {
+  Vue.directive('clipboard', {
+    bind: function () {
+      this.clipboard = new Clipboard(".clipboard-btn");
+    },
+    update: function (value) {
+    },
+    unbind: function () {
+      this.clipboard.destroy()
+    }
+  });
+
+  Vue.directive('t', {
+    update: function (value, key) {
+      this.el.innerHTML = key;
+    }
+  });
+
+  if ('ontouchstart' in window) {
+    var edown = "touchstart";
+    var emove = "touchmove";
+    var eup = "touchend";
+  } else {
+    var edown = "mousedown";
+    var emove = "mousemove";
+    var eup = "mouseup";
+  }
+
+  Vue.directive('videoplayer', {
+    update: function (a) {
+      var el = this.el;
+      var scope = this.vm.$root;
+      var video = el.querySelectorAll("video")[0];
+      var play_button = el.querySelectorAll(".play")[0];
+      var pause_button = el.querySelectorAll(".pause")[0];
+      var stop_button = el.querySelectorAll(".stop")[0];
+      var player_state = "stop";
+
+      var update_view = function() {
+        try {
+          if (!a.player_view) { a.player_view = {} };
+          a.player_view.state = player_state;
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }
+
+      var play_func = function() {
+        video.play();
+        player_state = "playing";
+        update_view();
+      }
+
+      var pause_func = function() {
+        try {
+          video.pause();
+          player_state = "paused";
+          update_view();
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }
+
+      var stop_func = function() {
+        try {
+          player_state = "stop";
+          video.pause();
+          video.currentTime = 0;
+          update_view();
+
+        } catch (e) {
+          // catch InvalidStateError
+        }      
+      }
+
+      el.addEventListener("remote_play",play_func);
+      el.addEventListener("remote_pause",pause_func);
+      el.addEventListener("remote_stop",stop_func);
+
+      play_button.addEventListener(edown, function(evt) {
+        try {
+          play_func();
+          spacedeck.presenter_send_media_action(a._id,"video","play",video.currentTime);
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }, false);
+
+      pause_button.addEventListener(edown, function(evt) {
+        pause_func();
+        spacedeck.presenter_send_media_action(a._id,"video","pause",video.currentTime);
+      }, false);
+
+      stop_button.addEventListener(edown, function(evt) {
+        stop_func();
+        spacedeck.presenter_send_media_action(a._id,"video","stop",0);
+      }, false);
+    }
+  });
+
+  Vue.directive('audioplayer', {
+    update: function (a) {
+      var el = this.el;
+      var scope = this.vm.$root;
+      var play_button = el.querySelectorAll(".play")[0];
+      var pause_button = el.querySelectorAll(".pause")[0];
+      var stop_button = el.querySelectorAll(".stop")[0];
+      var timeline = el.querySelectorAll(".timeline")[0];
+      var set_inpoint = el.querySelectorAll(".set-inpoint")[0];
+      var set_outpoint = el.querySelectorAll(".set-outpoint")[0];
+      var reset_points = el.querySelectorAll(".reset-points")[0];
+
+      var player_state = "stop";
+
+      var play_from = 0.0;
+      var play_to = 0.0;
+
+      var audio = el.querySelectorAll("audio")[0];
+
+      var update_markers = function() {
+        try {
+          if (a.meta) {
+            if (!a.meta.play_to) a.meta.play_to = audio.duration;
+            play_from = parseFloat(a.meta.play_from) || 0.0;
+            play_to = parseFloat(a.meta.play_to) || 0.0;
+          } else {
+            play_from = 0.0;
+            play_to = parseFloat(audio.duration) || 0.0;
+            a.meta = {};
+          }
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }
+
+      var update_view = function() {
+        try {
+          if (!a.player_view) { a.player_view = {} };
+          a.player_view.state = player_state;
+          a.player_view.total_time_string = format_time(audio.duration);
+          a.player_view.current_time_string = format_time(audio.currentTime);
+          a.player_view.current_time_float = audio.currentTime/audio.duration;
+          a.player_view.inpoint_float = play_from/audio.duration;
+          a.player_view.outpoint_float = play_to/audio.duration;
+          a.player_view.duration = audio.duration;
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }
+
+      var pause_audio = function() {
+        try {
+          audio.pause();
+          player_state = "paused";
+        } catch (e) {
+          // catch InvalidStateError
+        }
+        update_view();
+      }
+
+      var stop_audio = function() {
+        try {
+          audio.currentTime = play_from;
+          audio.pause();
+          player_state = "stop";
+        } catch (e) {
+          // catch InvalidStateError
+        }
+        update_view();
+      }
+
+      update_view();
+
+      audio.addEventListener("loadedmetadata", function(evt) {
+        update_markers();
+        update_view();
+      }, false);
+
+      audio.addEventListener("timeupdate", function(evt) {
+        try {
+          update_markers();
+          if (audio.currentTime >= play_to && player_state == "playing") stop_audio();
+          update_view();
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }, false);
+
+      var play_func = function() {
+        if (player_state == "stop") {
+          audio.currentTime = play_from;
+        }
+
+        player_state = "playing";
+        update_markers();
+        audio.play();
+        update_view();
+      }
+
+      var pause_func = function() {
+        pause_audio();
+        update_view();
+      }
+
+      var stop_func = function() {
+        stop_audio();
+        update_view();
+      }
+
+      el.addEventListener("remote_play",play_func);
+      el.addEventListener("remote_pause",pause_func);
+      el.addEventListener("remote_stop",stop_func);
+      
+      play_button.addEventListener(edown, function(evt) {
+        try {
+          play_func();
+          spacedeck.presenter_send_media_action(a._id,"audio","play",audio.currentTime);
+        } catch (e) {
+          // catch InvalidStateError
+        }
+      }, false);
+
+      pause_button.addEventListener(edown, function(evt) {
+        pause_func();
+        spacedeck.presenter_send_media_action(a._id,"audio","pause",audio.currentTime);
+      }, false);
+
+      stop_button.addEventListener(edown, function(evt) {
+        stop_func();
+        spacedeck.presenter_send_media_action(a._id,"audio","stop",0);
+      }, false);
+
+      timeline.addEventListener(edown, function(evt) {
+        var clicked_time = (parseFloat(evt.offsetX)/evt.currentTarget.offsetWidth)*audio.duration;
+        if (isNaN(clicked_time)) {
+          clicked_time = 0.0;
+        }
+        try {
+          audio.currentTime = clicked_time;
+        } catch (e) {
+          // catch InvalidStateErrors
+        }
+      }, false);
+
+      set_inpoint.addEventListener(edown, function(evt) {
+        if (!a.meta) a.meta = {};
+        
+        a.meta.play_from = audio.currentTime;
+        if (a.meta.play_to<a.meta.play_from) a.meta.play_to = audio.duration;
+        update_markers();
+        stop_audio();
+        update_view();
+        scope.save_artifact(a);
+      }, false);
+
+      set_outpoint.addEventListener(edown, function(evt) {
+        if (!a.meta) a.meta = {};
+        
+        a.meta.play_to = audio.currentTime;
+        if (a.meta.play_to<a.meta.play_from) a.meta.play_from = 0.0;
+        update_markers();
+        stop_audio();
+        update_view();
+        scope.save_artifact(a);
+      }, false);
+
+      reset_points.addEventListener(edown, function(evt) {
+        if (!a.meta) a.meta = {};
+        
+        a.meta.play_from = 0.0;
+        a.meta.play_to = audio.duration;
+        update_markers();
+        stop_audio();
+        update_view();
+        scope.save_artifact(a);
+      }, false);
+    }
+  });
+
+  Vue.directive('sd-richtext', {
+    twoWay: true,
+    update: function(obj) {
+      this.mode = 'rich';
+
+      $(this.el).addClass("text-editing");
+
+      this.medium = new Medium({
+        element: this.el,
+        mode: Medium.richMode,
+        attributes: {
+		      remove: ['class','href','onclick','onmousedown','onmouseup']
+	      },
+      });
+      this.medium.value(obj.description);
+      this.medium.element.addEventListener('keyup', function() {
+        obj.description = this.medium.value();
+        spacedeck.queue_artifact_for_save(obj);
+      }.bind(this));
+
+      spacedeck.medium_for_object[obj._id] = this.medium;
+    }
+  });
+
+  Vue.directive('focus', {
+    bind: function () {
+      var el = this.el;
+      window.setTimeout(function() {
+        if (el.contentEditable && el.contentEditable!="inherit") {
+          var range = document.createRange();
+          range.selectNodeContents(el);
+        } else {
+          el.focus();
+          el.select();
+        }
+      }, 500);
+    },
+  });
+
+  Vue.directive('sd-draggable', {
+    update: function(data) {
+      var el = this.el;
+
+      el.addEventListener(
+        'dragstart',
+        function(evt) {
+          if ($(el).find(".text-editing").length) {
+            // FIXME: technical debt
+            evt.stopPropagation();
+            evt.preventDefault();
+            return;
+          }
+
+          evt.dataTransfer.setData('application/json', JSON.stringify(data));
+          $(el).addClass("dragging");
+        },
+        false
+      );
+    }
+  });
+
+  Vue.directive('sd-droppable', {
+    isFn: true,
+    bind: function() {
+      var el = this.el;
+      var expression = this.expression;
+      var parts = expression.split(";");
+      var func_key = parts[0];
+      var data_key = parts[1];
+
+      el.addEventListener(
+        'dragover',
+        function(e) {
+          e.dataTransfer.dropEffect = 'copy';
+          // allows us to drop
+          if (e.preventDefault) e.preventDefault();
+          el.classList.add('over');
+
+          return false;
+        }.bind(this),
+        false
+      );
+
+      el.addEventListener(
+        'dragenter',
+        function(e) {
+          el.classList.add('over');
+
+          return false;
+        }.bind(this),
+        false
+      );
+
+      el.addEventListener(
+        'dragleave',
+        function(e) {
+          el.classList.remove('over');
+          return false;
+        },
+        false
+      );
+
+      el.addEventListener(
+        'drop',
+        function(e) {
+          e.stopPropagation();
+          e.preventDefault();
+
+          $(e.currentTarget).find(".over").removeClass('over');
+          $(e.currentTarget).find(".dragging").removeClass('dragging');
+
+          var func = this.vm.$root[func_key].bind(this.vm.$root);
+          if (this._scope) {
+            var obj = this._scope[data_key];
+          } else {
+            var obj = this.vm[data_key];
+          }
+          func(e, obj);
+
+          return false;
+        }.bind(this),
+        false
+      );
+    }
+  });
+
+  Vue.directive('sd-fader', {
+    bind: function (section) {
+
+      function clamp(v, mn, mx) {
+        return Math.max(mn,Math.min(mx,v));
+      }
+
+      var scope = this.vm.$root;
+
+      this.fader_state = "idle";
+      this.fader_mx = 0;
+      this.fader_my = 0;
+
+      var $el = $(this.el);
+
+      var handle = $el.find(".fader-selector");
+      var indicator = $el.find(".fader-indicator");
+      var constraint = $el.find(".fader-constraint");
+      if (!constraint.length) constraint = $el;
+
+      var fader_var_x = $el.attr("sd-fader-var-x");
+      var fader_var_y = $el.attr("sd-fader-var-y");
+
+      var knob_size = 0;
+      var minx = 0;
+      var miny = 0;
+      var maxx = 0;
+      var maxy = 0;
+
+      var nx = 0;
+      if (xfader) nx = scope.$get(fader_var_x);
+      var ny = 0;
+      if (yfader) ny = scope.$get(fader_var_y);
+
+      var xfader = !!fader_var_x;
+      var yfader = !!fader_var_y;
+      var encoder = !handle[0];
+
+      var step = parseFloat($el.attr("sd-fader-step"))||1;
+      var sensitivity = parseFloat($el.attr("sd-fader-sens"))||1;
+
+      var discover_minmax = function() {
+        minx = (parseInt($el.attr("sd-fader-min-x"))||0);
+        miny = (parseInt($el.attr("sd-fader-min-y"))||0);
+        maxx = parseInt($el.attr("sd-fader-max-x"))||(constraint.width() - 1);
+        maxy = parseInt($el.attr("sd-fader-max-y"))||(constraint.height() - 1);
+      }
+
+      var position_handle = function() {
+        discover_minmax();
+        if (!nx || isNaN(nx)) nx = 0;
+        if (!ny || isNaN(ny)) ny = 0;
+
+        if (handle[0]) {
+          if (xfader) handle[0].style.left = nx+"px";
+          if (yfader) handle[0].style.top  = (maxy-ny)+"px";
+        }
+
+        if (indicator[0]) {
+          indicator[0].style.height = ny+"px";
+        }
+      }.bind(this);
+
+      var move_handle = function(dx,dy) {
+        discover_minmax();
+        if (xfader) {
+          nx = clamp(dx, minx, maxx);
+          scope.$set(fader_var_x, nx);
+        }
+
+        if (yfader) {
+          ny = clamp(dy, miny, maxy);
+          if (step<1) ny = ny.toFixed(1); // float precision hack
+
+          scope.$set(fader_var_y, ny);
+        }
+      }.bind(this);
+
+      var handle_move = function(evt) {
+        evt = fixup_touches(evt);
+
+        var dx = parseInt((evt.pageX - this.fader_mx) * sensitivity);
+        var dy = parseInt((evt.pageY - this.fader_my) * sensitivity);
+
+        dx *= step;
+        dy *= step;
+
+        move_handle(this.fader_oldx+dx,this.fader_oldy-dy);
+      }.bind(this);
+
+      var handle_up = function(evt) {
+        this.fader_state = "idle";
+
+        $("body").off(emove, handle_move);
+        $("body").off("mouseleave "+eup+" blur", handle_up);
+
+        window._sd_fader_moving = false; // signal for other event systems
+      }.bind(this);
+
+      function prevent_event(evt) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      };
+
+      $el.on(edown,function(evt) {
+        evt.preventDefault();
+        evt.stopPropagation();
+
+        evt = fixup_touches(evt);
+        var offset = $(evt.target).offset();
+
+        this.fader_state = "drag";
+        if (!encoder) {
+          move_handle(evt.pageX-offset.left, maxy - (evt.pageY - offset.top) + knob_size/2);
+        }
+
+        if (yfader) {
+          ny = scope.$get(fader_var_y);
+        }
+
+        $("body").on(emove, handle_move);
+        $("body").on("mouseleave "+eup+" blur", handle_up);
+
+        this.fader_mx = evt.pageX;
+        this.fader_my = evt.pageY;
+        this.fader_oldx = nx||0;
+        this.fader_oldy = ny||0;
+
+        window._sd_fader_moving = true; // signal for other event systems
+      }.bind(this));
+
+      // initial state
+      position_handle();
+
+      if (xfader) {
+        scope.$watch(fader_var_x, function(a) {
+          nx = parseInt(scope.$get(fader_var_x));
+          position_handle();
+        });
+      }
+
+      if (yfader) {
+        scope.$watch(fader_var_y, function(a) {
+          ny = parseInt(scope.$get(fader_var_y));
+          position_handle();
+        });
+      }
+    },
+
+    unbind: function() {
+      var scope = this.vm.$root;
+      var $el = $(this.el);
+      var fader_var_x = $el.attr("sd-fader-var-x");
+      var fader_var_y = $el.attr("sd-fader-var-y");
+    }
+  });
+}

+ 20 - 0
public/javascripts/spacedeck_formatting.js

@@ -0,0 +1,20 @@
+/*
+  SpacedeckFormatting
+  This module contains functions dealing with Rich Text Formatting.
+*/
+
+var SpacedeckFormatting = {
+  apply_formatting: function(section, cmd, arg1, arg2) {
+    console.log("apply_formatting: ",section,cmd);
+
+    var scribe = _scribe_handle_for_object[section._id];
+    var command = scribe.getCommand(cmd);
+
+    if (cmd == 'createLink') {
+      arg1 = prompt("Link URL?");
+    }
+
+    scribe.el.focus();
+    command.execute(arg1,arg2);
+  }
+}

+ 101 - 0
public/javascripts/spacedeck_modals.js

@@ -0,0 +1,101 @@
+
+var SpacedeckModals = {
+  data: {
+    active_modal: null,
+    active_account_section: "user",
+    active_space_profile_section: null,
+
+    account_sections: [
+      {
+        id: "user",
+        title: "Profile",
+        icon:  "icon-user",
+      },
+      {
+        id: "language", 
+        title: "Language",
+        icon:  "icon-globe",
+      },
+      {
+        id: "email-notifications", 
+        title: "Notifications",
+        icon:  "icon-bell",
+      },
+      {
+        id: "reset-password", 
+        title: "Password",
+        icon:  "icon-lock-closed",
+      },
+      {
+        id: "remove-account", 
+        title: "Terminate",
+        icon:  "icon-logout",
+      }
+    ],
+    folder_profile_sections: [
+      {
+        id: "editors", 
+        title: "Editors",
+        icon:  "icon-user-group",
+        count: 1
+      },   
+      {
+        id: "visibility", 
+        title: "Visibility",
+        icon:  "icon-eye-open",
+        count: 1
+      }
+    ],
+
+    space_profile_sections: [
+      {
+        id: "comments", 
+        title: "Comments",
+        icon:  "icon-messages",
+        count: 1
+      },
+      {
+        id: "history", 
+        title: "History",
+        icon:  "icon-history",
+        count: 1
+      },
+      {
+        id: "editors", 
+        title: "Editors",
+        icon:  "icon-user-group",
+        count: 1
+      },
+      {
+        id: "visibility", 
+        title: "Visibility",
+        icon:  "icon-eye-open",
+        count: 1
+      }
+    ]
+  },
+
+  methods: {
+    activate_modal: function(id) {
+      this.active_modal = id;
+
+      if (id == "folder-settings") {
+        this.access_settings_space = this.active_folder;
+        this.access_settings_memberships = this.active_space_memberships;
+        this.editors_section = "list";
+      }
+    },
+
+    close_modal: function() {
+      this.active_modal = null;
+    },
+
+    activate_account_section: function(section_id) {
+      this.active_account_section = section_id;
+    },
+
+    activate_space_profile_section: function(section_id) {
+      this.active_space_profile_section = section_id;
+    }
+  }
+}

+ 294 - 0
public/javascripts/spacedeck_routes.js

@@ -0,0 +1,294 @@
+/*
+  SpacedeckRoutes
+  This module contains functions dealing with Routing and View Switching.
+*/
+
+var SpacedeckRoutes = {
+
+  internal_route: function(path, on_success) {
+    if(!this.router) {
+      this.router = new RouteRecognizer();
+
+      this.router.add([
+        {
+          path: "/spaces/:id",
+          handler: function(params, on_success) {
+            this.load_space(params.id, on_success);
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/confirm/:token",
+          handler: function(params) {
+            if (!this.logged_in) {
+              this.redirect_to("/login");
+            } else {
+              this.confirm_account(params.token);
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/password-confirm/:token",
+          handler: function(params) {
+
+            console.log(params.token);
+
+            if (this.logged_in) {
+              this.redirect_to("/spaces");
+            } else {
+              this.reset_token = params.token;
+              this.active_view = "password-confirm";
+            }
+
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/password-reset",
+          handler: function(params, test) {
+            if (this.logged_in) {
+            } else {
+              this.active_view = "password-reset";
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/accept/:membership_id",
+          handler: function(params, test) {
+            if (this.logged_in) {
+              var invitation_token = get_query_param("code");
+              accept_invitation(params.membership_id, invitation_token , function(m) {
+                window._spacedeck_location_change = true;
+                location.href = "/spaces/"+m.space._id;
+              }.bind(this), function(xhr) {
+                smoke.alert("Error ("+xhr.status+")", function() {
+                  this.redirect_to("/spaces");
+                }.bind(this));
+              }.bind(this));
+            } else {
+              this.redirect_to("/login");
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/signup",
+          handler: function(params) {
+            var invitation_token = get_query_param("code");
+
+            if (invitation_token) {
+              this.invitation_token = invitation_token;
+            }
+            
+            if (this.logged_in) {
+              this.redirect_to("/spaces");
+            } else {
+              this.active_view = "signup";
+            }
+
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/login",
+          handler: function(params) {
+            if (this.logged_in) {
+              if(this.invitation_token) {
+                accept_invitation(this.accept_invitation, function(m) {
+                  window._spacedeck_location_change = true;
+                  location.href = "spaces/"+m.space_id;
+                }.bind(this), function(xhr) { console.error(xhr); });
+              } else {
+                this.redirect_to("/spaces");
+              }
+            } else {
+              this.active_view = "login";
+              token = get_query_param("code");
+              if (token) {
+                this.login_with_token(token);
+              }
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/logout",
+          handler: function(params) {
+            if (this.logged_in) {
+              this.logout(function(m) {
+                this.redirect_to("/login");
+              }.bind(this), function(xhr) { console.error(xhr); });
+            } else {
+              this.redirect_to("/login");
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/spaces",
+          handler: function(params) {
+            if (!this.logged_in) {
+              window._spacedeck_location_change = true;
+              location.href = "/login";
+            } else {
+
+              if (this.logged_in && this.user.home_folder_id) {
+                this.load_space(this.user.home_folder_id);
+              } else {
+                location.href = "/";
+              }
+
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/account",
+          handler: function(params) {
+            if (!this.logged_in) {
+              window._spacedeck_location_change = true;
+              location.href = "/";
+            } else {
+              this.active_view = "account";
+            }
+          }.bind(this)
+        }
+      ]);
+
+
+      this.router.add([
+        {
+          path: "/team",
+          handler: function(params) {
+            if (!this.logged_in) {
+              window._spacedeck_location_change = true;
+              location.href = "/";
+            } else {
+              this.active_view = "team";
+              this.load_team();
+            }
+          }.bind(this)
+        }
+      ]);
+
+      this.router.add([
+        {
+          path: "/folders/:id",
+          handler: function(params) {
+            this.load_space(params.id, null, function(xhr) {
+              // on_error
+
+              console.log("couldn't load folder: "+xhr.status);
+              this.redirect_to("/spaces", function(){});
+            }.bind(this));
+          }.bind(this)
+        }
+
+      ]);
+
+      this.router.add([
+        {
+          path: "/",
+          handler: function(params) {
+            location.href = "/";
+          }.bind(this)
+        }
+      ]);
+      
+      this.router.add([
+        {
+          path: "/terms",
+          handler: function(params) {
+            location.href = "/terms";
+          }.bind(this)
+        }
+      ]);
+      
+      this.router.add([
+        {
+          path: "/privacy",
+          handler: function(params) {
+            location.href = "/privacy";
+          }.bind(this)
+        }
+      ]);
+    }
+
+    var foundRoute = this.router.recognize(path);
+    if (foundRoute) {
+      foundRoute[0].handler(foundRoute[0].params, on_success);
+    } else {
+      location.href = "/not_found";
+    }
+  },
+
+  route: function() {
+    window.onpopstate = function (event) {
+      event.preventDefault();
+      this.internal_route(location.pathname);
+    }.bind(this);
+
+    $("body").on("click", "a", function(event) {
+      // #hash
+      if (event.currentTarget.hash && event.currentTarget.hash.length>1) return;
+
+      // external link?
+      if (event.currentTarget.host != location.host) return;
+
+      // modifier keys?
+      if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+
+      // /t/ path
+      if (event.currentTarget.pathname.match(/^\/t\//)) return;
+
+      this.internal_route(event.currentTarget.pathname);
+      history.pushState(null, null, event.currentTarget.pathname);
+
+      event.preventDefault();
+    }.bind(this));
+
+    this.internal_route(location.pathname);
+  },
+  
+  open_url: function(url) {
+    window.open(url,'_blank');
+  },
+  
+  redirect_to: function(path, on_success) {
+    if (on_success) {
+      this.internal_route(path, on_success);
+      history.pushState(null, null, path);
+    } else {
+      window._spacedeck_location_change = true;
+      location.href = path;
+    }
+  },
+
+  link_to_parent_folder: function(space_id) {
+    return "/folders/"+space_id;
+  },
+
+  link_to_space: function(space) {
+    return "/"+space.space_type+"s/"+space._id;
+  }
+}

+ 3015 - 0
public/javascripts/spacedeck_sections.js

@@ -0,0 +1,3015 @@
+/*
+  SpacedeckSections
+  This module contains functions dealing with Space Sections UI.
+*/
+
+var SpacedeckSections = {
+
+  data: {
+    MAX_COLUMNS: 20,
+
+    redo_stack: [],
+    undo_stack: [],
+
+    opened_dialog: "none", // which property panel to display
+    color_options_picker: false,
+    advanced_properties: false,
+
+    embed_code_html: "",
+    active_tool: "pointer",
+    lightbox_artifact: {},
+    snap_ruler_y: -1000,
+    snap_ruler_x: -1000,
+
+    minimap_width: 100,
+    minimap_height: 200,
+    minimap_scale: 10,
+    scroll_left: 0,
+    scroll_top: 0,
+    window_width: 800,
+    window_height: 600,
+    bounds_margin_horiz: 0,
+    bounds_margin_vert: 0,
+
+    editing_artifact_id: null,
+    selected_artifacts_dict: {},
+    first_selected_artifact: null,
+    selection_metrics: {
+      contains_text: false,
+      contains_images: false,
+      contains_audio: false,
+      contains_vectors: false,
+      contains_shapes: false,
+      borders_stylable: true,
+      count: 0,
+      x: 0,
+      y: 0,
+      w: 0,
+      h: 0,
+      x1: 0,
+      y1: 0,
+      x2: 0,
+      y2: 0,
+      style: "display:none",
+      vector_points: [{},{}],
+      vector_selection: false
+    },
+    selected_artifacts_json: "",
+    zones: [],
+    user_cursors: [],
+
+    default_style: {}, // will be copied from active_style on startup
+
+    active_style: {
+      border_radius: 0,
+      stroke: 0,
+      font_family: "Avenir W01",
+      font_size: 18,
+      line_height: 1.5,
+      letter_spacing: 0,
+
+      stroke_color: "#000000",
+      fill_color: "#00000000",
+      text_color: "#000000",
+      background_color: "#ffffff",
+
+      padding: 0,
+      padding_horz: 0,
+      padding_vert: 0,
+      padding_top: 0,
+      padding_left: 0,
+      padding_right: 0,
+      padding_bottom: 0,
+
+      margin: 0,
+      margin_horz: 0,
+      margin_vert: 0,
+      margin_top: 0,
+      margin_left: 0,
+      margin_right: 0,
+      margin_bottom: 0,
+
+      brightness: 100,
+      contrast: 100,
+      opacity: 100,
+      saturation: 100,
+      blur: 0,
+      hue: 0,
+
+      columns: 1,
+      column_width: 900,
+      row_height: 0,
+      gutter: 0,
+    },
+
+    color_picker_target: "fill_color",
+
+    color_picker_saturation: 255,
+    color_picker_value: 255,
+    color_picker_hue: 127,
+    color_picker_opacity: 255,
+
+    swatches: [
+      {id:0, hex:"#4a2f7e"},
+      {id:1, hex:"#9b59b6"},
+      {id:2, hex:"#3498db"},
+      {id:3, hex:"#2ecc71"},
+      {id:4, hex:"#f1c40f"},
+      {id:5, hex:"#e67e22"},
+      {id:6, hex:"#d55c4b"},
+      {id:7, hex:"#6f4021"},
+      {id:8, hex:"#ffffff"},
+      {id:9, hex:"#95a5a6"},
+      {id:10, hex:"#252525"},
+      {id:11, hex:"rgba(0,0,0,0)"},
+    ],
+    
+    swatches_text: [
+      {id:1, hex:"#9b59b6"},
+      {id:2, hex:"#3498db"},
+      {id:3, hex:"#2ecc71"},
+      {id:4, hex:"#f1c40f"},
+      {id:5, hex:"#e67e22"},
+      {id:6, hex:"#d55c4b"},
+      {id:8, hex:"#ffffff"},
+      {id:10, hex:"#252525"},
+    ],
+
+    fonts: [
+      "Arial",
+      "Courier",
+      "Georgia",
+      "Verdana",
+      "Comic Sans MS",
+      "Montserrat",
+      "Lato",
+      "Roboto",
+      "Crimson Text",
+      "EB Garamond",
+      "Vollkorn",
+      "Avenir W01"
+    ],
+
+    detected_text_formats: {},
+    active_text_format_name: "Paragraph",
+
+    image_search_results: [],
+    video_search_results: [],
+    audio_search_results: [],
+    generic_search_query: "",
+    media_search_target: "google",
+    search_loading: false,
+
+    viewport_zoom: 1,
+    viewport_zoom_percent: 100,
+    bounds_zoom: 1,
+
+    current_zone_idx: -1,
+
+    margin_mode: "global",
+    padding_mode: "global",
+    delete_artifact: "unconfirmed",
+    color_mode: "palette",
+    background_mode: "image",
+    layout_mode: "layout",
+    follow_mode: true,
+    space_background_uploading: false,
+
+    toolbar_props_x: 0,
+    toolbar_props_y: 0,
+    toolbar_props_in: false,
+    toolbar_artifacts_x: "-1000px",
+    toolbar_artifacts_y: "-1000px",
+    toolbar_artifacts_in: false
+  },
+
+  methods: {
+
+    setup_section_module: function() {
+
+      // defaults -----------------------------------------------------------------------------
+
+      this.default_style = _.clone(this.active_style);
+
+      // keybindings --------------------------------------------------------------------------
+
+      Mousetrap.bind('del', function(evt)             { this.if_editable(function() {this.delete_selected_artifacts(evt);}) }.bind(this));
+      Mousetrap.bind('backspace', function(evt)       { this.if_editable(function() {this.delete_selected_artifacts(evt);}) }.bind(this));
+      Mousetrap.bind(['command+d',      'ctrl+d'      ], function(evt) { evt.preventDefault(); evt.stopPropagation(); this.if_editable(function() {this.duplicate_selected_artifacts();}) }.bind(this));
+      Mousetrap.bind(['command+z',      'ctrl+z'      ], function(evt) { this.if_editable(function() {this.undo();}) }.bind(this));
+      Mousetrap.bind(['command+shift+z','ctrl+shift+z'], function(evt) { this.if_editable(function() {this.redo();}) }.bind(this));
+      Mousetrap.bind(['command+a',      'ctrl+a'      ], function(evt) { this.if_editable(function() {this.select_all_artifacts();}) }.bind(this));
+      Mousetrap.bind(['command+e',      'ctrl+e'      ], function(evt) { this.if_editable(function() {this.toggle_full_width();}) }.bind(this));
+      Mousetrap.bind(['command+=',      'ctrl+='      ], function(evt) { evt.preventDefault(); evt.stopPropagation(); this.zoom_in();  }.bind(this));
+      Mousetrap.bind(['command+-',      'ctrl+-'      ], function(evt) { evt.preventDefault(); evt.stopPropagation(); this.zoom_out(); }.bind(this));
+      Mousetrap.bind('+', function(evt)               { evt.preventDefault(); evt.stopPropagation(); this.zoom_in();  }.bind(this));
+      Mousetrap.bind('-', function(evt)               { evt.preventDefault(); evt.stopPropagation(); this.zoom_out(); }.bind(this));
+      Mousetrap.bind('up', function(evt)              { this.nudge_selected_artifacts(0,-1,evt);}.bind(this));
+      Mousetrap.bind('down', function(evt)            { this.nudge_selected_artifacts(0,1,evt);}.bind(this));
+      Mousetrap.bind('left', function(evt)            { this.nudge_selected_artifacts(-1,0,evt);}.bind(this));
+      Mousetrap.bind('right', function(evt)           { this.nudge_selected_artifacts(1,0,evt);}.bind(this));
+      Mousetrap.bind('shift+up', function(evt)        { this.if_editable(function() {this.nudge_selected_artifacts(0,-10,evt);}) }.bind(this));
+      Mousetrap.bind('shift+down', function(evt)      { this.if_editable(function() {this.nudge_selected_artifacts(0,10,evt);}) }.bind(this));
+      Mousetrap.bind('shift+left', function(evt)      { this.if_editable(function() {this.nudge_selected_artifacts(-10,0,evt);}) }.bind(this));
+      Mousetrap.bind('shift+right', function(evt)     { this.if_editable(function() {this.nudge_selected_artifacts(10,0,evt);}) }.bind(this));
+      Mousetrap.bind('space', function(evt)           { this.activate_pan_tool(evt); }.bind(this));
+      
+      $(document).bind("beforecopy", this.handle_onbeforecopy.bind(this));
+      $(window).bind("beforeunload", this.handle_onunload.bind(this));
+      $(window).bind("resize", this.handle_window_resize.bind(this));
+    },
+
+    setup_watches: function() {
+      this.$watch('active_style.stroke', function (value, mutation) {
+        this.set_artifact_style_prop("stroke",parseInt(this.active_style.stroke));
+      }.bind(this));
+
+      this.$watch('active_style.border_radius', function (value, mutation) {
+        this.set_artifact_style_prop("border_radius",parseInt(this.active_style.border_radius));
+      }.bind(this));
+
+      this.$watch('active_style.padding', function (value, mutation) {
+        this.active_style.padding_horz = this.active_style.padding;
+        this.active_style.padding_vert = this.active_style.padding;
+      }.bind(this));
+      this.$watch('active_style.padding_horz', function (value, mutation) {
+        this.active_style.padding_left = this.active_style.padding_horz;
+        this.active_style.padding_right = this.active_style.padding_horz;
+      }.bind(this));
+      this.$watch('active_style.padding_vert', function (value, mutation) {
+        this.active_style.padding_top = this.active_style.padding_vert;
+        this.active_style.padding_bottom = this.active_style.padding_vert;
+      }.bind(this));
+
+      this.$watch('active_style.padding_top', function (value, mutation) {
+        this.set_artifact_style_prop("padding_top",parseInt(this.active_style.padding_top));
+      }.bind(this));
+      this.$watch('active_style.padding_bottom', function (value, mutation) {
+        this.set_artifact_style_prop("padding_bottom",parseInt(this.active_style.padding_bottom));
+      }.bind(this));
+      this.$watch('active_style.padding_left', function (value, mutation) {
+        this.set_artifact_style_prop("padding_left",parseInt(this.active_style.padding_left));
+      }.bind(this));
+      this.$watch('active_style.padding_right', function (value, mutation) {
+        this.set_artifact_style_prop("padding_right",parseInt(this.active_style.padding_right));
+      }.bind(this));
+
+      this.$watch('active_style.margin', function (value, mutation) {
+        this.active_style.margin_horz = this.active_style.margin;
+        this.active_style.margin_vert = this.active_style.margin;
+      }.bind(this));
+
+      this.$watch('active_style.margin_horz', function (value, mutation) {
+        this.active_style.margin_left = this.active_style.margin_horz;
+        this.active_style.margin_right = this.active_style.margin_horz;
+      }.bind(this));
+      this.$watch('active_style.margin_vert', function (value, mutation) {
+        this.active_style.margin_top = this.active_style.margin_vert;
+        this.active_style.margin_bottom = this.active_style.margin_vert;
+      }.bind(this));
+
+      this.$watch('active_style.margin_top', function (value, mutation) {
+        this.set_artifact_style_prop("margin_top",parseInt(this.active_style.margin_top));
+      }.bind(this));
+      this.$watch('active_style.margin_bottom', function (value, mutation) {
+        this.set_artifact_style_prop("margin_bottom",parseInt(this.active_style.margin_bottom));
+      }.bind(this));
+      this.$watch('active_style.margin_left', function (value, mutation) {
+        this.set_artifact_style_prop("margin_left",parseInt(this.active_style.margin_left));
+      }.bind(this));
+      this.$watch('active_style.margin_right', function (value, mutation) {
+        this.set_artifact_style_prop("margin_right",parseInt(this.active_style.margin_right));
+      }.bind(this));
+
+      this.$watch('active_style.stroke_color', function (value, mutation) {
+        this.set_artifact_style_prop("stroke_color",this.active_style.stroke_color);
+
+        var rgba = hex_to_rgba(this.active_style.stroke_color);
+        var hsv = rgb_to_hsv(rgba.r, rgba.g, rgba.b);
+        this.active_style.stroke_color_hsv = hsv;
+      }.bind(this));
+
+      this.$watch('active_style.fill_color', function (value, mutation) {
+        this.set_artifact_style_prop("fill_color",this.active_style.fill_color);
+
+        var rgba = hex_to_rgba(this.active_style.fill_color);
+        var hsv = rgb_to_hsv(rgba.r, rgba.g, rgba.b);
+        this.active_style.fill_color_hsv = hsv;
+      }.bind(this));
+
+      this.$watch('active_style.text_color', function (value, mutation) {
+        this.set_artifact_style_prop("text_color",this.active_style.text_color);
+        this.apply_formatting(null,"forecolor",this.active_style.text_color);
+
+        var rgba = hex_to_rgba(this.active_style.text_color);
+        var hsv = rgb_to_hsv(rgba.r, rgba.g, rgba.b);
+        this.active_style.text_color_hsv = hsv;
+      }.bind(this));
+
+      this.$watch('active_style.font_size', function (value, mutation) {
+        this.apply_formatting(null,"preciseFontSize",this.active_style.font_size+"px");
+      }.bind(this));
+
+      this.$watch('active_style.line_height', function (value, mutation) {
+        this.apply_formatting(null,"lineHeight",this.active_style.line_height+"em");
+      }.bind(this));
+
+      this.$watch('active_style.letter_spacing', function (value, mutation) {
+        this.apply_formatting(null,"letterSpacing",this.active_style.letter_spacing+"px");
+      }.bind(this));
+
+      // color picker
+
+      this.$watch('color_picker_hue', function (value, mutation) {
+        this.apply_color_picker();
+      }.bind(this));
+
+      this.$watch('color_picker_value', function (value, mutation) {
+        this.apply_color_picker();
+      }.bind(this));
+
+      this.$watch('color_picker_saturation', function (value, mutation) {
+        this.apply_color_picker();
+      }.bind(this));
+
+      this.$watch('color_picker_opacity', function (value, mutation) {
+        this.apply_color_picker();
+      }.bind(this));
+
+      // filters
+
+      this.$watch('active_style.brightness', function (value, mutation) {
+        this.set_artifact_style_prop("brightness",parseInt(this.active_style.brightness));
+      }.bind(this));
+
+      this.$watch('active_style.blur', function (value, mutation) {
+        this.set_artifact_style_prop("blur",parseInt(this.active_style.blur));
+      }.bind(this));
+
+      this.$watch('active_style.contrast', function (value, mutation) {
+        this.set_artifact_style_prop("contrast",parseInt(this.active_style.contrast));
+      }.bind(this));
+
+      this.$watch('active_style.saturation', function (value, mutation) {
+        this.set_artifact_style_prop("saturation",parseInt(this.active_style.saturation));
+      }.bind(this));
+
+      this.$watch('active_style.hue', function (value, mutation) {
+        this.set_artifact_style_prop("hue",parseInt(this.active_style.hue));
+      }.bind(this));
+
+      this.$watch('active_style.opacity', function (value, mutation) {
+        this.set_artifact_style_prop("opacity",parseInt(this.active_style.opacity));
+      }.bind(this));
+
+      this.throttled_save_active_space = _.throttle(function(){
+        save_space(this.active_space);
+      }.bind(this), 2000);
+
+      // canvas
+      this.$watch('active_style.background_color', function (value, mutation) {
+
+        if (this.active_style.background_color != this.active_space.background_color) {
+          this.$set("active_space.background_color",this.active_style.background_color);
+          this.throttled_save_active_space();
+        }
+
+        var rgba = hex_to_rgba(this.active_style.background_color);
+        var hsv = rgb_to_hsv(rgba.r, rgba.g, rgba.b);
+        this.active_style.background_color_hsv = hsv;
+      }.bind(this));
+    },
+
+    if_editable: function(fn) {
+      // call given closure if space is editable
+      // used by key bindings
+      if (this.active_space_role!="viewer") fn.bind(this)();
+    },
+
+    background_image_style: function(images) {
+      if (!images) return null;
+      if (isNaN(images.length)) images = [images];
+
+      for (var i=0; i<images.length; i++) {
+        if (images[i] && images[i].length>0) {
+          return "background-image: url("+images[i]+")";
+        }
+      }
+    },
+
+    space_thumbnail_style: function(space) {
+      if (space.avatar_thumb_uri && space.avatar_thumb_uri.length>0) {
+        return "background-image:url('"+space.avatar_thumb_uri+"')";
+      }
+      if (space.space_type == "folder") return "";
+
+      return "background-image:url('/api/spaces/"+space._id+"/png')";
+    },
+
+    reset_artifact_filters: function() {
+      this.active_style.brightness = this.default_style.brightness;
+      this.active_style.contrast = this.default_style.contrast;
+      this.active_style.opacity = this.default_style.opacity;
+      this.active_style.saturation = this.default_style.saturation;
+      this.active_style.blur = this.default_style.blur;
+      this.active_style.hue = this.default_style.hue;
+    },
+
+    increase_columns: function() {
+      if (this.active_style.columns<this.MAX_COLUMNS) this.active_style.columns++;
+    },
+
+    decrease_columns: function() {
+      if (this.active_style.columns>1) this.active_style.columns--;
+    },
+
+    extract_properties_from_selection: function() {
+      // stop extract->apply feedback loop
+      this.skip_formatting = true;
+      
+      var arts = this.selected_artifacts();
+      window.setTimeout(function() {
+        this.skip_formatting = false;
+      }.bind(this),10);
+
+      if (!arts.length) return;
+
+      if (arts.length == 1) {
+        var a = arts[0];
+
+        var props = [
+          "stroke",
+          "border_radius",
+          "letter_spacing",
+          "stroke_color",
+          "fill_color",
+          "text_color"
+        ];
+
+        for (var i=0; i<props.length; i++) {
+          var prop = props[i];
+          this.active_style[prop]=a[prop];
+        }
+
+        // defaults
+        //this.active_style.padding = this.default_style.padding;
+        this.active_style.font_size = this.default_style.font_size;
+        this.active_style.line_height = this.default_style.line_height;
+        this.active_style.letter_spacing = this.default_style.letter_spacing;
+
+        this.active_style.padding_top =    a.padding_top || 0;
+        this.active_style.padding_bottom = a.padding_bottom || 0;
+        this.active_style.padding_left =   a.padding_left || 0;
+        this.active_style.padding_right =  a.padding_right || 0;
+
+        if (this.active_style.padding_top == this.active_style.padding_bottom) {
+          this.active_style.padding_vert = this.active_style.padding_top;
+        }
+
+        if (this.active_style.padding_left == this.active_style.padding_right) {
+          this.active_style.padding_horz = this.active_style.padding_left;
+        }
+
+        if (this.active_style.padding_top  == this.active_style.padding_bottom &&
+            this.active_style.padding_left == this.active_style.padding_right &&
+            this.active_style.padding_left == this.active_style.padding_top) {
+          this.active_style.padding = this.active_style.padding_top;
+        }
+
+        this.active_style.margin_top =    a.margin_top || 0;
+        this.active_style.margin_bottom = a.margin_bottom || 0;
+        this.active_style.margin_left =   a.margin_left || 0;
+        this.active_style.margin_right =  a.margin_right || 0;
+
+        if (this.active_style.margin_top == this.active_style.margin_bottom) {
+          this.active_style.margin_vert = this.active_style.margin_top;
+        }
+
+        if (this.active_style.margin_left == this.active_style.margin_right) {
+          this.active_style.margin_horz = this.active_style.margin_left;
+        }
+
+        if (this.active_style.margin_top  == this.active_style.margin_bottom &&
+            this.active_style.margin_left == this.active_style.margin_right &&
+            this.active_style.margin_left == this.active_style.margin_top) {
+          this.active_style.margin = this.active_style.margin_top;
+        }
+      }
+
+      this.update_selection_metrics();
+
+      // extract text styles
+      this.selection_metrics.contains_text = false;
+      this.selection_metrics.contains_images = false;
+      this.selection_metrics.contains_audio = false;
+      this.selection_metrics.contains_embeds = false;
+      this.selection_metrics.contains_vectors = false;
+      this.selection_metrics.contains_shapes = false;
+      this.selection_metrics.borders_stylable = false;
+
+      var notes = _.filter(arts, function(a) { return (a.mime=="text/html" || a.mime=="x-spacedeck/shape") });
+
+      if (notes.length>=1) {
+        this.selection_metrics.contains_text = true;
+
+        if (notes.length==1) {
+          var a = notes[0];
+          var dom = $("<div>"+a.description+"</div>")[0];
+          var el = dom.firstChild;
+
+          do {
+            if (el && el.style) {
+              if (el.style.fontSize) this.active_style.font_size = parseInt(el.style.fontSize);
+              if (el.style.fontFamily) this.active_style.font_family = el.style.fontFamily;
+              if (el.style.letterSpacing) this.active_style.letter_spacing = parseInt(el.style.letterSpacing);
+              if (el.style.lineHeight) this.active_style.line_height = parseFloat(el.style.lineHeight);
+              if (el.style.color) this.active_style.text_color = el.style.color;
+            }
+          } while (el && (el = dom.nextSibling));
+        }
+      }
+
+      if (arts.length == 1) {
+        this.extract_color_picker_from_selection();
+      }
+
+      var images = _.filter(arts, function(a) { return a.mime.match("image") });
+      if (images.length>=1) {
+        this.selection_metrics.contains_images = true;
+      }
+
+      var audio = _.filter(arts, function(a) { return a.mime.match("audio") });
+      if (audio.length>=1) {
+        this.selection_metrics.contains_audio = true;
+      }
+
+      var embeds = _.filter(arts, function(a) { return a.mime.match("embed") });
+      if (embeds.length>=1) {
+        this.selection_metrics.contains_embeds = true;
+      }
+
+      var embeds = _.filter(arts, function(a) { return a.mime=="x-spacedeck/vector" });
+      if (embeds.length>=1) {
+        this.selection_metrics.contains_vectors = true;
+      }
+
+      var embeds = _.filter(arts, function(a) { return a.mime=="x-spacedeck/shape"; });
+      if (embeds.length>=1) {
+        this.selection_metrics.contains_shapes = true;
+      }
+
+      var sm = this.selection_metrics;
+      this.selection_metrics.borders_stylable = !(sm.contains_vectors||sm.contains_shapes);
+    },
+
+    increase_letter_spacing: function(evt) {
+      this.active_style.letter_spacing++;
+    },
+
+    decrease_letter_spacing: function(evt) {
+      this.active_style.letter_spacing--;
+    },
+
+    apply_font: function(evt, font) {
+      this.apply_formatting(evt,'fontName',font);
+      this.active_style.font_family = font;
+    },
+
+    toggle_advanced_properties: function() {
+      this.advanced_properties = !this.advanced_properties;
+    },
+
+    open_dialog: function(id, evt) {
+      if (evt) {
+        evt.stopPropagation();
+        evt.preventDefault();
+      }
+
+      this.active_tool = "pointer";
+
+      if (this.opened_dialog == id) {
+        this.opened_dialog = "none";
+        return;
+      }
+
+      if (_.contains(["mobile","shapes","zones"],id)) {
+        this.deselect();
+      }
+      
+      this.opened_dialog=id;
+
+      if (id.match("color") || id.match("background")) {
+        this.color_picker_target = id.replace("color-","")+"_color";
+        this.color_mode = "palette";
+        this.extract_color_picker_from_selection();
+      }
+
+      if (_.contains(["audio","video","image","search"],id)) {
+        if ($("#"+id+" input")[0]) {
+          $("#"+id+" input")[0].focus();
+        }
+      }
+
+      if (this.opened_dialog == "background") {
+        this.color_picker_target = "background_color";
+        this.background_mode='color';
+      }
+
+      if (this.opened_dialog == "info") {
+        this.access_settings_space = this.active_space;
+        this.access_settings_memberships = this.active_space_memberships;
+        this.editors_section = "list";
+
+        if (this.active_space_is_readonly || this.embedded) {
+          this.space_info_section = "info";
+        } else if (this.active_space_role == "admin") {
+          this.space_info_section = "access";
+        }
+      }
+    },
+
+    toggle_color_options: function() {
+      this.color_options_picker = !this.color_options_picker;
+    },
+
+    close_lightbox: function() {
+      this.lightbox_artifact = {};
+      this.close_modal();
+    },
+
+    /* --------------- artifact manipulation ------------------------- */
+
+    prepare_clipboard: function() {
+      if ('ontouchstart' in window) return; // don't do this on touch devices
+
+      this.selected_artifacts_json = JSON.stringify(this.selected_artifacts());
+      //$("#space-clipboard > textarea")[0].blur();
+
+      this.prepare_clipboard_step2();
+    },
+
+    prepare_clipboard_step2: function() {
+      if ('ontouchstart' in window) return; // don't do this on touch devices
+
+      setTimeout(function() {
+        if (!$("#space-clipboard > textarea").length) return; // not ready yet
+
+        $("#space-clipboard > textarea")[0].focus();
+        $("#space-clipboard > textarea")[0].select();
+      },100);
+    },
+
+    handle_section_keydown: function(evt) {
+      if (evt.keyCode == 67 && (evt.ctrlKey || evt.metaKey)) { // c key
+        this.prepare_clipboard();
+        this.prepare_clipboard_step2();
+      }
+      return true;
+    },
+
+    handle_onbeforecopy: function(evt) {
+      if (this.editing_artifact_id) return;
+
+      var focused_tag = evt.target.nodeName.toLowerCase();
+      if (focused_tag != "body") return;
+
+      this.prepare_clipboard_step2();
+
+      window.setTimeout(function() {
+        if (!$("#space-clipboard > textarea").length) return; // not ready yet
+        $("#space-clipboard > textarea")[0].blur();
+      },10);
+    },
+
+    handle_onunload: function(evt) {
+      if (!window.artifact_save_queue) return;
+
+      var changes = Object.keys(window.artifact_save_queue).length;
+
+      if (changes>0) {
+        var message = "There are "+changes+" changes that are still being saved. Discard them?";
+        evt.returnValue = message;
+        return message;
+      }
+
+      window._spacedeck_location_change = true;
+    },
+
+    handle_window_resize: function(evt) {
+      this.adjust_bounds_zoom();
+    },
+
+    handle_scroll: function(evt) {
+      if (this.active_view!="space") return;
+      if (!$("#space").length) return;
+
+      el = $("#space")[0];
+      this.scroll_left   = el.scrollLeft/this.viewport_zoom;
+      this.scroll_top    = el.scrollTop/this.viewport_zoom;
+      this.window_width  = window.innerWidth/this.viewport_zoom;
+      this.window_height = window.innerHeight/this.viewport_zoom;
+
+      this.resize_minimap();
+
+      // follow presenter mode: send viewport rectangle to viewers
+      if (this.logged_in && this.present_mode) {
+        if (this.active_space_role!="viewer") {
+          this.presenter_send_viewport();
+        }
+      }
+    },
+
+    presenter_send_viewport: function() {
+      name = this.user.nickname || this.user.email;
+      
+      var msg = {
+        action: "viewport",
+        x: this.scroll_left,
+        y: this.scroll_top,
+        w: this.window_width,
+        h: this.window_height,
+        zoom: this.viewport_zoom,
+        name: name,
+        id: this.user._id
+      };
+
+      var packed = JSON.stringify(msg);
+      if (packed==this._old_viewport_msg) return;
+      this._old_viewport_msg = packed;
+      
+      if (this.present_mode && this.active_space_role!="viewer")
+        this.websocket_send(msg);
+    },
+
+    presenter_send_media_action: function(artifact_id,type,cmd,time) {
+      name = this.user.nickname || this.user.email;
+      
+      var msg = {
+        action: "media",
+        artifact_id: artifact_id,
+        type: type,
+        command: cmd,
+        time: time,
+        name: name,
+        id: this.user._id
+      };
+      if (this.present_mode && this.active_space_role!="viewer")
+        this.websocket_send(msg);
+    },
+
+    resize_minimap: function() {
+      if (!this.active_space) return;
+      this.minimap_scale = this.active_space.width/100.0;
+    },
+
+    handle_minimap_mouseup: function(evt) {
+      this.minimap_mouse_state = "idle";
+    },
+
+    handle_minimap_mousemove: function(evt) {
+      if (this.minimap_mouse_state=="pressed") {
+        this.handle_minimap_mousedown(evt);
+      }
+    },
+
+    handle_minimap_mousedown: function(evt) {
+      if (!$("#space").length) return;
+
+      this.minimap_mouse_state = "pressed";
+      el = $("#space")[0];
+
+      evt = fixup_touches(evt);
+
+      var ofs = $(evt.target).offset();
+      var x = evt.pageX - ofs.left;
+      var y = evt.pageY - ofs.top;
+
+      el.scrollLeft = (x-this.window_width/(this.minimap_scale*2))*this.minimap_scale*this.viewport_zoom;
+      el.scrollTop  = (y-this.window_height/(this.minimap_scale*2))*this.minimap_scale*this.viewport_zoom;
+      this.handle_scroll();
+    },
+
+    handle_user_cursor_update: function(msg) {
+      // console.log("handle cursor", msg);
+      var now = new Date().getTime();
+      msg.t = now;
+      var existing = false;
+      for (var i=0; i<this.user_cursors.length; i++) {
+        var u = this.user_cursors[i];
+
+        if (u.id == msg.id) {
+          u.x = msg.x;
+          u.y = msg.y;
+          u.t = now;
+          u.name = msg.name;
+          // console.log("updated cursor "+i);
+          existing = true;
+        } else {
+          // hide if no updates since 2sec
+          if ((now-u.t)>5000) {
+            u.x=-10000;
+          }
+        }
+      }
+      if (!existing) {
+        this.user_cursors.push(_.clone(msg));
+      }
+    },
+
+    handle_presenter_viewport_update: function(msg) {
+      this.zoom_to_rect({
+        x1: msg.x,
+        y1: msg.y,
+        x2: msg.x+msg.w,
+        y2: msg.y+msg.h
+      });
+    },
+    
+    handle_presenter_media_update: function(msg) {
+      if(this.follow_mode) {
+        if (msg.type=="audio") {
+          var sel="#artifact-"+msg.artifact_id+" .audio";
+          try {
+            $(sel)[0].dispatchEvent(new Event("remote_"+msg.command));
+            console.log("event dispatched");
+          } catch (e) {
+          }
+        }
+        if (msg.type=="video") {
+          var sel="#artifact-"+msg.artifact_id+" .video";
+          try {
+            $(sel)[0].dispatchEvent(new Event("remote_"+msg.command));
+            console.log("event dispatched");
+          } catch (e) {
+          }
+        }
+      } else {
+        console.log("ignore media update, muted");
+      }
+    },
+
+    may_select: function(a) {
+      if (!a) return false;
+      if (!this.active_space) return false;
+      
+      if (this.active_space_role=="viewer" || (a.locked && this.active_space_role!="admin")) {
+        return false;
+      }
+
+      if (this.active_space.editors_locking && !this.logged_in && this.guest_nickname!=a.editor_name) {
+        return false;
+      }
+      return true;
+    },
+
+    select: function(evt, a) {
+      if (!this.may_select(a)) return;
+
+      if (evt && !evt.shiftKey && this.is_selected(a)) return; // already selected
+
+      if (!evt || !evt.shiftKey) {
+        this.deselect();
+      }
+
+      if (evt && evt.shiftKey) {
+        if (this.selected_artifacts_dict[a._id]) {
+          delete this.selected_artifacts_dict[a._id];
+        } else {
+          this.selected_artifacts_dict[a._id] = true;
+        }
+      } else {
+        this.selected_artifacts_dict[a._id] = true;
+      }
+
+      this.update_board_artifact_viewmodel(a);
+      this.extract_properties_from_selection();
+      this.update_selection_metrics();
+      this.prepare_clipboard();
+      this.show_toolbar_props();
+    },
+
+    select_all_artifacts: function(evt) {
+      this.deselect();
+      for (var i=0; i<this.active_space_artifacts.length; i++) {
+        var a = this.active_space_artifacts[i];
+        if (this.may_select(a)) {
+          this.selected_artifacts_dict[a._id] = true;
+          this.update_board_artifact_viewmodel(a);
+        }
+      }
+
+      this.update_selection_metrics();
+      this.extract_properties_from_selection();
+      this.prepare_clipboard();
+      this.show_toolbar_props();
+    },
+
+    multi_select: function(arts) {
+      for (var i=0; i<arts.length; i++) {
+        var a = arts[i];
+        if (this.may_select(a)) {
+          this.selected_artifacts_dict[a._id] = true;
+          this.update_board_artifact_viewmodel(a);
+        }
+      }
+
+      this.extract_properties_from_selection();
+      this.update_selection_metrics();
+      this.prepare_clipboard();
+      this.show_toolbar_props();
+    },
+
+    discover_zones: function() {
+      this.zones = _.sortBy(_.filter(this.active_space_artifacts, function(a) { return (a.mime=="x-spacedeck/zone") }),
+        function(z){return z.order});
+    },
+
+    artifact_plaintext: function(a) {
+      if (!a) return "";
+      var txt = $("<div>"+a.description+"</div>").text();
+      return txt || "";
+    },
+
+    deselect: function(hard) {
+      if (window._sd_fader_moving) {
+        window._sd_fader_moving = false; // signal from fader directive
+        return;
+      }
+
+      this.hide_toolbar_props();
+
+      document.getSelection().removeAllRanges();
+
+      blur();
+      //this.prepare_clipboard();
+      this.prepare_clipboard_step2();
+      this.discover_zones();
+
+      var prev_selected = this.selected_artifacts();
+      this.selected_artifacts_dict = {};
+
+      // nuke empty notes
+      for (var i=0; i<prev_selected.length; i++) {
+        var a = prev_selected[i];
+        var keep = true;
+        if (a && a.mime == "text/html") {
+          var txt = this.artifact_plaintext(a);
+          if (txt.length == 0) {
+            keep = true; //Quick Fix For yassin, complains about buggy behaviour
+          }
+        }
+
+        if (!keep) {
+          this.selected_artifacts_dict[a._id] = a;
+        }
+      }
+      this.delete_selected_artifacts(null, true); // delete all selected in loop
+
+      this.selected_artifacts_dict = {};
+      this.editing_artifact_id = null;
+      this.opened_dialog = "none";
+
+      for (var i=0; i<prev_selected.length; i++) {
+        this.update_board_artifact_viewmodel(prev_selected[i]);
+      }
+
+      /*this.selection_metrics.x=0;
+      this.selection_metrics.y=0;
+      this.selection_metrics.w=0;
+      this.selection_metrics.h=0;
+      this.selection_metrics.style="display:none";*/
+      this.selection_metrics.contains_text=false;
+      this.selection_metrics.count=0;
+
+      if (hard) {
+        this.active_tool = "pointer";
+        this.mouse_state = "idle";
+      }
+
+      this.update_selection_metrics();
+    },
+
+    is_selected: function(a) {
+      if (!a) return;
+      return (!!this.selected_artifacts_dict[a._id]);
+    },
+
+    unselected_artifacts: function() {
+      return this.active_space_artifacts.filter(function(a){return !this.is_selected(a)}.bind(this));
+    },
+
+
+    selection_rect_style: function() {
+      var r = this.selection_rect();
+      if (r==null) return "display:none";
+
+      return "left:"+r.x1+"px;top:"+r.y1+"px;width:"+(r.x2-r.x1)+"px;height:"+(r.y2-r.y1)+"px;";
+    },
+
+    selection_rect: function() {
+      return this.enclosing_rect(this.selected_artifacts());
+    },
+
+    enclosing_rect: function(arts) {
+      if (arts.length==0) return null;
+      arts = _.filter(arts); // remove any nulls
+
+      return {
+        x1: parseInt(_.min(arts.map(function(a){return ((!a || !a.x)?0:a.x)}))),
+        y1: parseInt(_.min(arts.map(function(a){return ((!a || !a.y)?0:a.y)}))),
+        x2: parseInt(_.max(arts.map(function(a){return (!a?0:a.x+a.w)}))),
+        y2: parseInt(_.max(arts.map(function(a){return (!a?0:a.y+a.h)})))
+      };
+    },
+
+    update_selection_metrics: function(arts) {
+
+      if (this.active_tool == "scribble") {
+        this.selection_metrics.count = 1;
+        return;
+      }
+      
+      var sr = this.selection_rect() || {x:0,y:0,w:0,h:0,style:"display:none"};
+
+      if (sr.x1 || sr.x2) {
+        sr.w = sr.x2-sr.x1;
+        sr.h = sr.y2-sr.y1;
+
+        sr.style = this.selection_rect_style();
+
+        var pp = this.space_point_to_window(sr.x1+sr.w/2,sr.y2);
+        var pp2 = this.space_point_to_window(sr.x1+sr.w/2,sr.y1);
+
+        pp.x-=260;
+        pp.y-=10;
+
+        if (pp.y>=window.innerHeight-300) {
+          pp.y = pp2.y-100;
+        }
+        if (pp.x<0) {
+          pp.x=0;
+        }
+        if (pp.y<0) {
+          pp.y=0;
+        }
+
+        // FIXME make sure that menus fit in window
+        this.toolbar_props_x = pp.x+"px";
+        this.toolbar_props_y = pp.y+"px";
+          
+        this.hide_toolbar_artifacts();
+      }
+
+      this.selection_metrics.x1 = sr.x1;
+      this.selection_metrics.x2 = sr.x2;
+      this.selection_metrics.y1 = sr.y1;
+      this.selection_metrics.y2 = sr.y2;
+      this.selection_metrics.x = sr.x;
+      this.selection_metrics.y = sr.y;
+      this.selection_metrics.w = sr.w;
+      this.selection_metrics.h = sr.h;
+      this.selection_metrics.style = sr.style;
+
+      if (!arts) arts = this.selected_artifacts();
+
+      this.first_selected_artifact = arts[0];
+      this.selection_metrics.count=arts.length;
+      this.selection_metrics.scribble_selection = false;
+      if (arts.length == 1 && arts[0].mime == "x-spacedeck/vector") {
+        if (arts[0].shape == "scribble") {
+          this.selection_metrics.scribble_selection = true;
+        }
+        this.selection_metrics.vector_points = arts[0].control_points;
+        this.selection_metrics.vector_selection = true;
+      } else {
+        this.selection_metrics.vector_points = [{},{}];
+        this.selection_metrics.vector_selection = false;
+      }
+      this.selection_metrics.has_link=false;
+      this.insert_link_url="";
+      if (arts.length == 1 && arts[0].meta && arts[0].meta.link_uri && arts[0].meta.link_uri.length>0) {
+        this.selection_metrics.has_link=true;
+        this.insert_link_url = arts[0].meta.link_uri;
+      }
+    },
+
+    begin_transaction: function() {
+      this.transaction_running = true;
+
+      if (!this.undo_stack.length || this.undo_stack[this.undo_stack.length-1].action!="empty") {
+        this.undo_stack.push({action:"empty"});
+      } else {
+        //console.log("undo slot is already empty.");
+      }
+
+      this.redo_stack = [];
+
+      this.artifacts_before_transaction = this.active_space_artifacts.map(function(a) {
+        return _.cloneDeep(a);
+      });
+    },
+
+    fixup_space_size: function() {
+      if (!this.active_space) return;
+
+      this.active_space.width =Math.max(this.active_space.width, window.innerWidth);
+      this.active_space.height=Math.max(this.active_space.height, window.innerHeight);
+    },
+
+    end_transaction: function() {
+      this.transaction_running = false;
+      this.throttled_process_artifact_save_queue();
+
+      if (!this.active_space) return;
+
+      var er = this.enclosing_rect(this.active_space_artifacts);
+      if (!er) return;
+
+      this.active_space.width =Math.max(er.x2+100, window.innerWidth);
+      this.active_space.height=Math.max(er.y2+100, window.innerHeight);
+
+      if (this._last_bounds_width != this.active_space.width ||
+        this._last_bounds_height != this.active_space.height) {
+        this._last_bounds_width = this.active_space.width;
+        this._last_bounds_height = this.active_space.height;
+
+        save_space(this.active_space);
+      }
+
+      this.resize_minimap();
+      this.discover_zones();
+    },
+
+    find_artifact_before_transaction: function(needle) {
+      return this.find_artifact_in_array(this.artifacts_before_transaction, needle);
+    },
+
+    find_artifact_in_array: function(haystack, needle) {
+      var res = _.find(haystack, function(a) {
+        return (needle._id && (a._id == needle._id));
+      });
+      return res;
+    },
+
+    unsaved_transactions: function() {
+      if (!window.artifact_save_queue) return 0;
+      return Object.keys(window.artifact_save_queue).length;
+    },
+
+    process_artifact_save_queue: function() {
+      if (!window.artifact_save_queue) {
+        return;
+      }
+
+      if (this.transaction_running) {
+        console.log("not saving, transaction still in progress.");
+        return;
+      }
+
+      var ids = Object.keys(window.artifact_save_queue);
+
+      for (var i=0; i<ids.length; i++) {
+        var id = ids[i];
+        var a = window.artifact_save_queue[id];
+
+        if (this.guest_nickname) {
+          a.editor_name = this.guest_nickname;
+        }
+
+        save_artifact(a, function() {
+          delete window.artifact_save_queue[id];
+        }.bind(this), function(req) {
+          if (req && req.status == 404) {
+            // artifact was already deleted, ignore
+            delete window.artifact_save_queue[id];
+          } else {
+            console.log("could not save artifact, will try again:",a,req);
+          }
+        });
+      }
+
+      // mark version dirty locally
+      if (this.active_space) {
+        this.active_space.updated_at = (new Date()).getTime();
+      }
+
+      //window.artifact_save_queue = {};
+    },
+
+    throttled_process_artifact_save_queue: function() {
+      if (!this._throttled_process_artifact_save_queue) {
+        this._throttled_process_artifact_save_queue = _.throttle(this.process_artifact_save_queue, 500);
+      }
+      this._throttled_process_artifact_save_queue();
+    },
+
+    queue_artifact_for_save: function(a) {
+      if (!window.artifact_save_queue) {
+        window.artifact_save_queue = {};
+      }
+
+      if (!a._id) {
+        console.log("warning: illegal artifact queued for save");
+      }
+
+      // this is a bit hacky, but might be the smartest place to do it
+      if (a.view && a.view.vector_svg) {
+        a.shape_svg = a.view.vector_svg;
+      }
+
+      window.artifact_save_queue[a._id] = a;
+    },
+
+    update_properties: function(artifact_ids, changes) {
+      for (var i=0; i<artifact_ids.length; i++) {
+        var id = artifact_ids[i];
+
+        var a = this.find_artifact_by_id(id);
+        if (a) {
+          var changed = false;
+
+          for (k in changes[i]) {
+            //console.log("change: ",k,": ",changes[i][k],"<-",a[k])
+            a[k] = changes[i][k];
+            changed = true;
+          }
+
+          this.update_board_artifact_viewmodel(a);
+
+          // TODO: throttle, bundle etc
+          if (changed) {
+            this.queue_artifact_for_save(a);
+          }
+        }
+      }
+    },
+
+    update_artifacts: function(artifacts, change_func) {
+      var artifact_ids = [];
+      var changes = [];
+
+      for (var i=0; i<artifacts.length; i++) {
+        var a = artifacts[i];
+        var c = change_func(a);
+        if (c) {
+          artifact_ids.push(a._id);
+          changes.push(c);
+        }
+      }
+
+      if (changes.length) {
+        this.push_to_undo({
+          action: "update",
+          artifact_ids: artifact_ids,
+          changes: changes,
+          snapshot: this.artifacts_before_transaction
+        });
+      }
+
+      if (changes.length) {
+        this.update_properties(artifact_ids, changes);
+      }
+    },
+
+    push_to_undo: function(transaction) {
+      // begin_transaction opens new undo slot
+      // push_to_undo replaces the current undo slot
+
+      this.undo_stack[this.undo_stack.length-1] = transaction;
+      //console.log(transaction.action+"; undo_stack len: ",this.undo_stack.length);
+    },
+
+    undo: function() {
+      if (!this.undo_stack.length || this.undo_stack[this.undo_stack.length-1].action=="empty") {
+        console.log("nothing to undo!");
+        return;
+      }
+
+      var step = this.undo_stack.pop();
+      console.log("undo popped: ",step);
+      this.redo_stack.push(step);
+
+      for (var i=0; i<step.artifact_ids.length; i++) {
+        var id = step.artifact_ids[i];
+        var old_version = this.find_artifact_in_array(step.snapshot, {_id:id});
+
+        if (step.action == "update") {
+          if (old_version) {
+            this.update_properties([id], [old_version]);
+          }
+        } else {
+          delete old_version._id;
+          save_artifact(old_version, function(restored_a) {
+            this.update_board_artifact_viewmodel(restored_a);
+            this.active_space_artifacts.push(restored_a);
+            // TODO: rewrite undo history's artifact ids
+          }.bind(this));
+        }
+      }
+      this.update_selection_metrics();
+    },
+
+    redo: function() {
+      if (!this.redo_stack.length) {
+        console.log("nothing to redo!");
+        return;
+      }
+
+      var step = this.redo_stack.pop();
+      console.log("redo popped: ",step);
+      this.undo_stack.push(step);
+
+      this.update_properties(step.artifact_ids, step.changes);
+      this.update_selection_metrics();
+    },
+
+    set_artifact_prop: function(prop, val) {
+      this.begin_transaction();
+
+      this.update_selected_artifacts(function(a) {
+        var c = {};
+
+        if (a[prop] != val) {
+          //console.log("set_artifact_prop: ",c,val);
+          c[prop]=val;
+          return c;
+        }
+
+        return null;
+      });
+    },
+
+    set_artifact_style_prop: function(prop, val) {
+      this.begin_transaction();
+
+      this.update_selected_artifacts(function(a) {
+        var c = {};
+        
+        if (a[prop] != val) {
+          //console.log("set_artifact_style_prop: ",c,val);
+          c[prop]=val;
+          return c;
+        }
+
+        return null;
+      });
+    },
+
+    activate_color_mode: function(mode) {
+      this.color_mode = mode;
+      if (mode == 'picker') {
+        // default to full alpha if color is 0,0,0,0
+        if (this.color_picker_hue == 0 &&
+          this.color_picker_saturation == 0 &&
+          this.color_picker_value == 0 &&
+          this.color_picker_opacity == 0) {
+          this.color_picker_opacity = 255;
+          this.color_picker_value = 255;
+        }
+      }
+    },
+
+    reset_stroke: function() {
+      this.active_style.stroke = 0;
+      this.active_style.border_radius = 0;
+      this.active_style.stroke_style = "solid";
+    },
+
+    apply_font_size: function(px) {
+      this.apply_formatting(null,"preciseFontSize",px+"px");
+    },
+
+    apply_swatch_color: function(swatch) {
+
+      var rgba = hex_to_rgba(swatch.hex);
+
+      var hsv = rgb_to_hsv(rgba.r, rgba.g, rgba.b);
+
+      this.color_picker_hue = parseFloat(hsv.h*255);
+      this.color_picker_saturation = parseFloat(hsv.s*255);
+      this.color_picker_value = parseFloat(hsv.v*255);
+      this.color_picker_opacity = rgba.a*255;
+      this.color_picker_rgb = rgb_to_hex(rgba.r,rgba.g,rgba.b);
+
+      //console.log("swatch hex: ",swatch.hex);
+
+      this.active_style[this.color_picker_target] = swatch.hex;
+
+      if (this.color_picker_target == "stroke_color") {
+        if (!this.active_style.stroke) {
+          // set a default stroke for convenience
+          this.active_style.stroke = 2;
+        }
+      }
+    },
+
+    apply_color_picker: function() {
+      var rgb = hsv_to_rgb(this.color_picker_hue/255,
+        this.color_picker_saturation/255,
+        this.color_picker_value/255);
+      var alpha = this.color_picker_opacity/255;
+
+      //console.log("apply_color_picker: ",rgb,alpha);
+
+      this.active_style[this.color_picker_target] = "rgba("+[rgb.r,rgb.g,rgb.b,alpha].join(",")+")";
+    },
+
+    extract_color_picker_from_selection: function() {
+
+      if (this.selected_artifacts().length!=1 && this.opened_dialog!="background") return;
+
+      if (this.opened_dialog=="background") {
+        this.active_style[this.color_picker_target] = this.active_space.background_color;
+      } else {
+        if (!this.active_style[this.color_picker_target]) {
+          this.active_style[this.color_picker_target] = this.default_style[this.color_picker_target];
+        }
+      }
+      //console.log("active_style for "+this.color_picker_target+": ",this.active_style[this.color_picker_target]);
+
+      var rgba = hex_to_rgba(this.active_style[this.color_picker_target]);
+
+      var hsv = rgb_to_hsv(rgba.r, rgba.g, rgba.b);
+
+      this.color_picker_hue = parseFloat(hsv.h*255);
+      this.color_picker_saturation = parseFloat(hsv.s*255);
+      this.color_picker_value = parseFloat(hsv.v*255);
+      this.color_picker_opacity = parseInt(rgba.a);
+      this.color_picker_rgb = rgb_to_hex(rgba.r,rgba.g,rgba.b);
+    },
+
+    update_selected_artifacts: function(change_func, override_locked) {
+      var artifacts = this.selected_artifacts(!override_locked);
+
+      if (!artifacts.length) return;
+
+      this.update_artifacts(artifacts, change_func);
+      this.update_selection_metrics();
+    },
+
+    nudge_selected_artifacts: function(dx, dy, event) {
+      if (this.present_mode) {
+        if (dx>0 || dy>0) {
+          this.go_to_next_zone();
+          return;
+        }
+        if (dx<0 || dy<0) {
+          this.go_to_previous_zone();
+          return;
+        }
+      }
+
+      if (!this.selected_artifacts().length) {
+        if (!$("#space").length) return;
+        var el = $("#space")[0];
+        el.scrollLeft+=dx*100;
+        el.scrollTop +=dy*100;
+        return;
+      }
+
+      if (this.active_space_is_readonly) return;
+
+      if (event) {
+        event.preventDefault();
+        event.stopPropagation();
+      }
+
+      this.begin_transaction();
+
+      this.update_selected_artifacts(function(a) {
+        return {
+          x: a.x+dx,
+          y: a.y+dy
+        };
+      });
+    },
+
+    /* -------------------------------------------------------------------- */
+
+    highest_z: function() {
+      var z = _.max(this.active_space_artifacts.map(function(a){return a.z||0}));
+      if (z<0) z=0;
+      if (z>999) z=999;
+      return z;
+    },
+
+    find_place_for_item: function(width, height) {
+      var arts = this.active_space_artifacts;
+
+      var tw = window.innerWidth;
+      var th = window.innerHeight;
+      var el = $("#space")[0];
+      
+      if (!el) return {x:0,y:0,z:1}; // FIXME
+
+      var wrap = $(".wrapper");
+      var wx = parseInt(wrap.css("margin-left"));
+      var wy = parseInt(wrap.css("margin-top"));
+
+      var x = parseInt((el.scrollLeft + tw/2)/this.viewport_zoom - width/2 - wx/this.viewport_zoom);
+      var y = parseInt((el.scrollTop  + th/2)/this.viewport_zoom - height/2 - wy/this.viewport_zoom);
+
+      /*
+      if (this.opened_dialog!="none") {
+        // we have less visible space if a dialog is obscuring sight
+        y/=2;
+      }
+      */
+
+      var z = this.highest_z()+1;
+
+      if (arts.length==0) return {x:x,y:y};
+
+      x += parseInt(Math.random()*20)-10;
+      y += parseInt(Math.random()*20)-10;
+
+      return {x:x,y:y,z:z};
+    },
+
+    save_audio_edit: function(a) { // just a helper to be called from view model
+      this.opened_dialog = "none";
+      this.update_board_artifact_viewmodel(a);
+      save_artifact(a);
+    },
+
+    save_artifact: function(a, on_success) { // helper to be called from view model
+      if (this.guest_nickname) {
+        a.editor_name = this.guest_nickname;
+      }
+
+      this.update_board_artifact_viewmodel(a);
+      save_artifact(a, on_success);
+    },
+
+    add_artifact: function (space, item_type, url, evt) {
+      this.active_tool = "pointer";
+      this.mouse_state = "idle";
+      this.hide_toolbar_artifacts();
+      
+      if (!url && (item_type == 'image' || item_type == 'video' || item_type == 'embed')) {
+        url = prompt("URL?");
+        if (!url || !url.length) return;
+      }
+
+      //this.opened_dialog = "none";
+
+      var w=300,h=200;
+      var z=this.highest_z()+1;
+
+      // TODO: find solution for legacy types
+      mimes = {
+        "text": "text/html",
+        "note": "text/html",
+        "image": "image/jpg",
+        "video": "video/mp4"
+      };
+
+      var new_item = {
+        mime: mimes[item_type],
+        description: "",
+        payload_uri: url,
+        payload_thumbnail_medium_uri: url || null,
+        payload_thumbnail_web_uri: url || null,
+        space_id: space._id,
+
+        order: this.active_space_artifacts.length+1,
+        valign: "middle",
+        align: "center"
+        //fill_color: "#f8f8f8"
+      };
+
+      if (mimes[item_type] == "text/html") {
+        new_item.padding_left = 10;
+        new_item.padding_top = 10;
+        new_item.padding_right = 10;
+        new_item.padding_bottom = 10;
+        new_item.fill_color = "rgba(255,255,255,1)";
+        new_item.description = "<p>Text</p>";
+      }
+
+      if (evt) {
+        var point = this.cursor_point_to_space(evt);
+        point.x-=100;
+        point.y-=100;
+      } else {
+        var point = this.find_place_for_item(w,h);
+        z = point.z;
+      }
+
+      new_item.x = parseInt(point.x);
+      new_item.y = parseInt(point.y);
+      new_item.z = z;
+      new_item.w = w;
+      new_item.h = h;
+
+      if (this.guest_nickname) {
+        new_item.editor_name = this.guest_nickname;
+      }
+      // console.log("new artifact", new_item);
+
+      save_artifact(new_item, function(saved_item) {
+
+        // console.log("saved artifact", saved_item);
+
+        this.update_board_artifact_viewmodel(saved_item);
+        this.active_space_artifacts.push(saved_item);
+
+        if (!url) {
+          this.select(null, saved_item);
+        }
+
+        if (item_type.match("text")) {
+          this.editing_artifact_id = saved_item._id;
+          window.setTimeout(function() {
+            // FIXME: replace hack
+            var el = $("#artifact-"+saved_item._id+" .text-editing");
+            focus_contenteditable(el[0], false);
+          },400);
+        }
+      }.bind(this));
+    },
+
+    go_to_first_zone: function() {
+      this.discover_zones();
+      if (!this.zones.length) return;
+      this.zoom_to_zone(this.zones[0]);
+    },
+
+    go_to_previous_zone: function() {
+      this.discover_zones();
+      if (!this.zones.length) return;
+      var prev_idx = (this.current_zone_idx-1);
+      if (prev_idx<0) prev_idx = this.zones.length-1;
+      this.current_zone_idx = prev_idx;
+      this.zoom_to_zone(this.zones[this.current_zone_idx]);
+    },
+
+    go_to_next_zone: function() {
+      this.discover_zones();
+      if (!this.zones.length) return;
+      var next_idx = ((this.current_zone_idx+1) % this.zones.length);
+      this.current_zone_idx = next_idx;
+      this.zoom_to_zone(this.zones[this.current_zone_idx]);
+    },
+
+    sort_zone_up: function(z) {
+      var idx = this.zones.indexOf(z);
+      if (idx<1) return;
+      var new_zones = _.flatten([this.zones.slice(0,idx-1),[z],this.zones[idx-1],this.zones.slice(idx+1,this.zones.length)]);
+      for (var i=0; i<new_zones.length; i++) {
+        if (new_zones[i]) {
+          if (!new_zones[i].style) new_zones[i].style = {};
+          new_zones[i].order = i;
+          save_artifact(new_zones[i]);
+        }
+      }
+      this.discover_zones();
+    },
+
+    sort_zone_down: function(z) {
+      var idx = this.zones.indexOf(z);
+      if (idx>=this.zones.length) return;
+      var new_zones = _.flatten([this.zones.slice(0,idx),this.zones[idx+1],[z],this.zones.slice(idx+2,this.zones.length)]);
+      for (var i=0; i<new_zones.length; i++) {
+        if (new_zones[i]) {
+          if (!new_zones[i].style) new_zones[i].style = {};
+          new_zones[i].order = i;
+          save_artifact(new_zones[i]);
+        }
+      }
+      this.discover_zones();
+    },
+
+    add_zone: function() {
+      var w = 600;
+      var h = 600;
+      var point = this.find_place_for_item(w,h);
+
+      var a = {
+        space_id: this.active_space._id,
+        mime: "x-spacedeck/zone",
+        description: "Zone "+(this.zones.length+1),
+        x: point.x,
+        y: point.y,
+        w: w,
+        h: h,
+        z: 0,
+        valign: "middle",
+        align: "center"
+      };
+
+      if (this.guest_nickname) {
+        a.editor_name = this.guest_nickname;
+      }
+
+      save_artifact(a, function(saved_item) {
+        this.update_board_artifact_viewmodel(saved_item);
+        this.active_space_artifacts.push(saved_item);
+
+        this.select(null, saved_item);
+      }.bind(this));
+    },
+
+    add_shape: function(shape_type, evt) {
+      var w = 200;
+      var h = 200;
+
+      if (shape_type=="cloud") {
+        w = 400;
+      }
+
+      //var point = this.find_place_for_item(w,h);
+      var point = this.cursor_point_to_space(evt);
+      
+      var a = {
+        space_id: this.active_space._id,
+        mime: "x-spacedeck/shape",
+        description: "Text",
+        x: point.x,
+        y: point.y,
+        z: point.z,
+        w: w,
+        h: h,
+        stroke_color: "#ffffff",
+        text_color: "#ffffff",
+        stroke: 0,
+        fill_color: "#000000",
+        shape: shape_type,
+        valign: "middle",
+        align: "center"
+      };
+
+      if (this.guest_nickname) {
+        a.editor_name = this.guest_nickname;
+      }
+
+      save_artifact(a, function(saved_item) {
+
+        this.update_board_artifact_viewmodel(saved_item);
+        this.active_space_artifacts.push(saved_item);
+
+        this.select(null, saved_item);
+      }.bind(this));
+    },
+
+    cursor_point_to_space: function(evt) {
+      if (!evt) return {x:0,y:0};
+      if (!$("#space").length) return {x:0,y:0};
+
+      var el = $("#space")[0];
+      var pt = parseInt($("#space").css("padding-top"));
+      var ml = 0;
+      var mt = 0;
+
+      var px = evt.pageX;
+      var py = evt.pageY;
+      if (!("pageX" in evt) && ("originalEvent" in evt)) {
+        px = evt.originalEvent.pageX;
+        py = evt.originalEvent.pageY;
+      }
+
+      var ox = ((px + el.scrollLeft) - this.bounds_margin_horiz)/this.viewport_zoom;
+      var oy = ((py + el.scrollTop) - pt - this.bounds_margin_vert)/this.viewport_zoom;
+      return {x: ox, y: oy};
+    },
+
+    space_point_to_window: function(x,y) {
+      var rx = 0;
+      var ry = 0;
+      
+      var el = $("#space")[0];
+      rx = x*this.viewport_zoom-el.scrollLeft+this.bounds_margin_horiz;
+      ry = y*this.viewport_zoom-el.scrollTop+this.bounds_margin_vert;
+
+      return {x:rx,y:ry};
+    },
+
+    create_artifact_via_upload: function(evt, file, multiple) {
+      if (this.active_space_role=="viewer") {
+        return false;
+      }
+      
+      this.hide_toolbar_artifacts();
+      
+      // 1. create placeholder artifact
+      var w=300,h=150;
+      var fill="transparent";
+      if (file.type.match("audio")) {
+        w=600;
+        h=150;
+        fill="#ffffff";
+      }
+
+      var point = this.cursor_point_to_space(evt);
+
+      point.x-=w/2;
+      point.y-=h/2;
+      if (multiple) {
+        point = this.find_place_for_item(w,h);
+      }
+
+      var a = {
+        space_id: this.active_space._id,
+        mime: file.type,
+        description: "Uploading…",
+        state: "uploading",
+        payload_thumbnail_medium_uri: null,
+        payload_thumbnail_web_uri: null,
+        x: point.x,
+        y: point.y,
+        w: w,
+        h: h,
+        z: point.z,
+        order: this.active_space_artifacts.length+1,
+        fill_color: fill
+      }
+
+      this.update_board_artifact_viewmodel(a);
+
+      // 2. post file
+      if (this.guest_nickname) {
+        a.editor_name = this.guest_nickname;
+      }
+
+      save_artifact(a, function(updated_a) {
+        a = updated_a;
+        this.update_board_artifact_viewmodel(a);
+        this.active_space_artifacts.push(a);
+        save_artifact_file(a, file, file.name, function(updated_a) {
+
+          console.log("file saved. result: ",updated_a);
+
+          // TODO: generify
+          a.payload_uri = updated_a.payload_uri;
+          a.payload_thumbnail_web_uri = updated_a.payload_thumbnail_web_uri;
+          a.payload_thumbnail_medium_uri = updated_a.payload_thumbnail_medium_uri;
+          a.payload_thumbnail_big_uri = updated_a.payload_thumbnail_big_uri;
+          a.payload_alternatives = updated_a.payload_alternatives;
+          a.mime = updated_a.mime;
+          a.x = updated_a.x;
+          a.y = updated_a.y;
+          a.w = updated_a.w;
+          a.h = updated_a.h;
+          a.z = updated_a.z;
+          a.state = updated_a.state;
+          this.update_board_artifact_viewmodel(a);
+
+        }.bind(this), null, function(e) {
+          // upload progress
+          var progress = e.loaded/e.total;
+
+          if (progress=1) {
+            a.description = "Converting Media…";
+          } else {
+            a.description = "Upload "+parseInt(progress*100)+"%";
+          }
+          this.update_board_artifact_viewmodel(a);
+          a.view.progress = parseInt(progress*100);
+        }.bind(this));
+      }.bind(this), this.display_saving_error);
+    },
+
+    delete_selected_artifacts: function(evt, skip_deselect) {
+      if (!this.active_space) return;
+
+      if (evt) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      }
+
+      this.begin_transaction();
+
+      var ids = this.selected_artifacts().map(function(a){return a._id});
+      var backup = [];
+      var backup_ids = [];
+
+      if (ids.length>1 && !skip_deselect) {
+        if (!confirm("Delete "+ids.length+" items?")) return;
+      }
+
+      for (var i=0; i<ids.length; i++) {
+        if (this.selected_artifacts_dict[ids[i]]) {
+          var id = ids[i];
+          var a = this.find_artifact_by_id(id);
+          if (a) {
+            backup.push(a);
+            backup_ids.push(id);
+            delete_artifact(a);
+          }
+
+          var idx = this.active_space_artifacts.indexOf(a);
+          this.active_space_artifacts.splice(idx, 1);
+        }
+      }
+
+      this.push_to_undo({
+        action: "delete",
+        artifact_ids: backup_ids,
+        snapshot: backup
+      });
+
+      if (!skip_deselect) {
+        this.deselect();
+      }
+    },
+
+    find_artifact_by_id: function(id) {
+      var ars = this.active_space_artifacts;
+      for (var i=0; i<ars.length; i++) {
+        var a = ars[i];
+        if (a._id==id) return a;
+      }
+      return null;
+    },
+
+    selected_artifacts: function(exclude_locked) {
+      if (!this.active_space || !this.active_space_artifacts) return [];
+      return this.active_space_artifacts.filter(function(a) {
+        var sel = this.artifact_is_selected(a);
+        if (sel && a.locked) return !exclude_locked;
+        return sel;
+      }.bind(this));
+    },
+
+    delayed_edit_artifact: function(evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+
+      var a = this.selected_artifacts()[0];
+
+      var el = $("#ios-focuser-"+a._id);
+      el.focus();
+      el.select();
+
+      this.toggle_selected_artifact_editing(true, true);
+    },
+
+    toggle_selected_artifact_editing: function(force_on, delayed) {
+      var a = this.selected_artifacts()[0];
+
+      if (!a) {
+        this.editing_artifact_id = null;
+        return;
+      }
+
+      if (this.editing_artifact_id == a._id && !force_on) {
+        this.editing_artifact_id = null;
+        return;
+      }
+
+      if (a.locked) return;
+
+      if (!_.include(["text/html","x-spacedeck/shape","x-spacedeck/zone"],a.mime)) return;
+
+      if (this.editing_artifact_id == a._id) return;
+
+      this.editing_artifact_id = a._id;
+
+      var ms = 100;
+      if (delayed) ms = 500; // wait for slow devices
+
+      window.setTimeout(function(){
+        var el = $("#artifact-"+a._id+" .text-editing");
+        if (el[0]) {
+          // tuned weirdness for firefox, chrome + ios safari
+          //el.select();
+          focus_contenteditable(el[0], true);
+        } else {
+          // one more try (slow device)
+          window.setTimeout(function(){
+            var el = $("#artifact-"+a._id+" .text-editing");
+            //el.select();
+            focus_contenteditable(el[0], true);
+          },ms);
+        }
+      },ms);
+    },
+
+    clear_formatting_walk: function(el,cmd,arg1,arg2) {
+      if (el && el.style) {
+        if (cmd == "preciseFontSize") {
+          el.fontSize = null;
+        } else if (cmd == "letterSpacing") {
+          el.letterSpacing = null;
+        } else if (cmd == "lineHeight") {
+          el.lineHeight = null;
+        } else if (cmd == "fontName") {
+          el.fontFamily = null;
+        } else if (cmd == "fontWeight") {
+          el.fontWeight = null;
+          el.fontStyle = null;
+        } else if (cmd == "bold") {
+          el.fontWeight = null;
+        } else if (cmd == "italic") {
+          el.fontStyle = null;
+        } else if (cmd == "underline") {
+          el.textDecoration = null;
+        } else if (cmd == "strikeThrough") {
+          el.textDecoration = null;
+        } else if (cmd == "forecolor") {
+          el.color = null;
+        }
+      }
+
+      if (el && el.childNodes) {
+        for (var i=0; i<el.childNodes.length; i++) {
+          this.clear_formatting_walk(el.childNodes[i],cmd,arg1,arg2);
+        }
+      }
+    },
+
+    apply_formatting: function(evt,cmd,arg1,arg2) {
+      if (evt) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      }
+
+      if (this.skip_formatting) return;
+
+      if (cmd=='createlink') {
+        arg1 = prompt("Link URL?");
+        if (!arg1) return;
+      }
+
+      var selected = this.selected_artifacts();
+      var collapsed = false;
+
+      if (!window.selection || window.selection.type == "Caret" || window.selection.type == "None") {
+        collapsed = true;
+      }
+
+      // (selected.length==1 && (!this.editing_artifact_id))
+      if (!this.editing_artifact_id || cmd == "preciseFontSize" || cmd == "forecolor") {
+        for (var i=0; i<selected.length; i++) {
+          var a = selected[i];
+          var dom = $("<div>"+a.description+"</div>")[0];
+          var el = dom.firstChild;
+
+          do {
+            // clear nested styles first
+            if (el && el.childNodes) {
+              for (var j=0; j<el.childNodes.length; j++) {
+                this.clear_formatting_walk(el.childNodes[j],cmd,arg1,arg2);
+              }
+            }
+
+            // toggle new style
+            if (el && el.style) {
+              if (cmd == "preciseFontSize") {
+                if (arg1 == this.default_style.font_size+"px") {
+                  el.style.fontSize = null;
+                } else {
+                  el.style.fontSize = arg1;
+                }
+              } else if (cmd == "letterSpacing") {
+                if (arg1 == this.default_style.letter_spacing+"px") {
+                  el.style.letterSpacing = null;
+                } else {
+                  el.style.letterSpacing = arg1;
+                }
+              } else if (cmd == "lineHeight") {
+                if (arg1 == this.default_style.line_height+"em") {
+                  el.style.lineHeight = null;
+                } else {
+                  el.style.lineHeight = arg1;
+                }
+              } else if (cmd == "fontName") {
+                el.style.fontFamily = arg1;
+              } else if (cmd == "fontWeight") {
+                el.style.fontWeight = arg1;
+                el.style.fontStyle = arg2;
+              } else if (cmd == "bold") {
+                el.style.fontWeight = (el.style.fontWeight!="bold"?"bold":"normal");
+              } else if (cmd == "italic") {
+                el.style.fontStyle = (el.style.fontStyle!="italic"?"italic":"normal");
+              } else if (cmd == "underline") {
+                el.style.textDecoration = (el.style.textDecoration!="underline"?"underline":"none");
+              } else if (cmd == "strikeThrough") {
+                el.style.textDecoration = (el.style.textDecoration!="line-through"?"line-through":"none");
+              } else if (cmd == "forecolor") {
+                el.style.color = arg1;
+              }
+            }
+          } while (el && (el = el.nextSibling));
+
+          if (a.description!=dom.innerHTML) {
+            a.description = dom.innerHTML;
+
+            console.log("new DOM:",dom.innerHTML);
+            
+            this.update_board_artifact_viewmodel(a);
+            this.queue_artifact_for_save(a);
+
+            if (this.editing_artifact_id) {
+              var a = this.find_artifact_by_id(this.editing_artifact_id);
+              var medium = this.medium_for_object[a._id];
+              if (medium && a) {
+                medium.value(a.description);
+              }
+            }
+          }
+        }
+
+      } else if (this.editing_artifact_id) {
+        // text level selection
+        var a = this.find_artifact_by_id(this.editing_artifact_id);
+        var medium = this.medium_for_object[a._id];
+        
+        if (medium && a) {
+          medium.focus();
+          medium.element.focus();
+          medium.invokeElement(cmd);
+          
+          a.description = medium.value();
+          this.queue_artifact_for_save(a);
+        }
+      }
+
+      this.extract_text_format_from_selection();
+    },
+
+    remove_link_from_selected_artifacts: function() {
+      this.update_selected_artifacts(function(a) {
+        return {link_uri: ""};
+      });
+    },
+
+    create_link_on_selected_artifacts: function() {
+      var def = "";
+      var sa = this.selected_artifacts();
+      if (sa.length>=1) {
+        if (sa[0].meta && sa[0].meta.link_uri) {
+          def = sa[0].meta.link_uri;
+        }
+      }
+
+      var insert_link_url = prompt("URL:",def);
+
+      this.update_selected_artifacts(function(a) {
+        var update = {link_uri: insert_link_url};
+
+        if (a.payload_uri && a.payload_uri.match("webgrabber")) {
+          var enc_uri = encodeURIComponent(btoa(insert_link_url));
+          var thumb_uri = ENV.apiEndpoint + "/api/webgrabber/"+enc_uri;
+
+          update.payload_uri = thumb_uri;
+          update.payload_thumbnail_web_uri = thumb_uri;
+          update.payload_thumbnail_medium_uri = thumb_uri;
+          update.payload_thumbnail_big_uri = thumb_uri;
+        }
+        
+        return update;
+      });
+
+      this.opened_dialog = "none";
+    },
+
+    clone_artifact: function(a,dx,dy,on_success) {
+      var copy = _.cloneDeep(a);
+      delete copy["$index"];
+      delete copy["_id"];
+
+      if (dx) copy.x += dx;
+      if (dy) copy.y += dy;
+
+      copy.order = this.active_space_artifacts.length+1;
+
+      if (this.guest_nickname) {
+        copy.editor_name = this.guest_nickname;
+      }
+
+      copy.space_id = this.active_space._id;
+
+      save_artifact(copy, function(saved) {
+        this.update_board_artifact_viewmodel(saved);
+        this.active_space_artifacts.push(saved);
+        if (on_success) {
+          on_success(saved);
+        } else {
+          this.select(null,saved);
+        }
+      }.bind(this));
+      return copy;
+    },
+
+    toggle_lock_of_selected_artifacts: function() {
+      this.update_selected_artifacts(function(a) {
+        return {locked: !a.locked};
+      }, true);
+    },
+
+    duplicate_selected_artifacts: function() {
+      var arts = this.selected_artifacts();
+      for (var i=0; i<arts.length; i++) {
+        var a = arts[i];
+        var cloned = this.clone_artifact(a,50,50);
+      }
+    },
+
+    copy_selected_artifacts_to_clipboard: function() {
+      if ('ontouchstart' in window) return; // don't do this on touch devices
+
+      $("#space-clipboard").focus();
+      $("#space-clipboard").select();
+    },
+
+    handle_section_click: function(evt) {
+      if (evt.target == evt.currentTarget) {
+        this.deselect();
+      }
+    },
+
+    handle_space_doubleclick: function(evt) {
+      if (this.selected_artifacts().length) return;
+      if (this.guest_nickname) return;
+      if (this.active_space && this.active_space.access_mode == "public") return;
+
+      //this.add_artifact(this.active_space, "text", null, evt);
+    },
+
+    handle_body_click: function(evt) {
+      if (this.active_view == "space") {
+        this.handle_section_click(evt);
+      }
+      this.close_dropdown(evt);
+    },
+
+    extract_text_format_from_selection: function() {
+      if (!window.selection) return;
+
+      var parents = $(window.selection.baseNode).parents().toArray();
+
+      this.detected_text_formats = {};
+      var formats = {
+        "p":"Paragraph",
+        "h1":"Headline 1",
+        "h2":"Headline 2",
+        "h3":"Headline 3",
+        "h4":"Headline 4",
+        "h5":"Headline 5",
+        "h6":"Headline 6",
+        "ul":"Bullet List",
+        "ol":"Numbered List",
+        "blockquote":"Blockquote"
+      };
+
+      for (var i=0; i<parents.length; i++) {
+        var p = parents[i];
+        if (p.contentEditable=="true") break;
+        var nn = p.nodeName.toLowerCase();
+        if (formats[nn]) {
+          this.detected_text_formats[nn] = true;
+          this.active_text_format_name = formats[nn];
+        }
+      }
+    },
+
+    // called on artifact keyup
+    save_edited_artifact_text: function(evt) {
+
+      // FIXME
+      if (this.editing_artifact_id) {
+
+        //var scribe = _scribe_handle_for_object[this.editing_artifact_id];
+        var artifact = this.find_artifact_by_id(this.editing_artifact_id);
+        if (artifact) {
+          //artifact.description = scribe.getHTML();
+          this.queue_artifact_for_save(artifact);
+        }
+
+        this.extract_text_format_from_selection();
+      }
+
+    },
+
+    handle_section_paste: function(evt) {
+      if (this.editing_artifact_id) return;
+      var pastedText = null;
+
+      try {
+        pastedText = evt.clipboardData.getData('text/plain');
+      } catch (e) {
+      }
+
+      if (!pastedText) return;
+      
+      if (!pastedText.match(/<[a-zA-Z]+>/g)) {
+        // crappy heuristic if this is actually HTML
+        pastedText = pastedText.replace(/\n/g,"<br>");
+      }
+      
+      this.insert_embedded_artifact(pastedText);
+    },
+
+    insert_embedded_artifact: function(text) {
+      var space = this.active_space;
+      if (!space) return;
+
+      if (text[0]=='[' || text[0]=='{') {
+        // might be json
+        try {
+          parsed = JSON.parse(text);
+          if (text[0]=='{') parsed = [parsed];
+          
+          this.deselect();
+
+          for (var i=0; i<parsed.length; i++) {
+            if (parsed[i].mime) {
+              var z = this.highest_z()+1;
+              if (parsed.length==1) {
+                var w = parsed[i].w;
+                var h = parsed[i].h;
+                var point = this.find_place_for_item(w,h);
+                parsed[i].x = point.x;
+                parsed[i].y = point.y;
+                parsed[i].z = point.z;
+              } else {
+                parsed[i].x = parsed[i].x+50;
+                parsed[i].y = parsed[i].y+50;
+                parsed[i].y = parsed[i].z+z;
+              }
+              this.clone_artifact(parsed[i], 0,0, function(a) {
+                this.multi_select([a]);
+              }.bind(this));
+            }
+          }
+          return;
+        } catch(e) {
+          // not json
+        }
+      }
+
+      if (text.match(/^http[s]*\:\/\//)) {
+        // crude url matching
+        this.create_artifact_via_embed_url(text);
+        return;
+      }
+
+      var new_item = {
+        mime: "text/html",
+        description: text.replace("\n", "<br />"),
+        title: "",
+        space_id: space._id
+      };
+
+      var w = 400;
+      var h = 300;
+      var point = this.find_place_for_item(w,h);
+
+      new_item.x = point.x;
+      new_item.y = point.y;
+      new_item.w = w;
+      new_item.h = h;
+      new_item.z = point.z;
+
+      if (this.guest_nickname) {
+        new_item.editor_name = this.guest_nickname;
+      }
+
+      save_artifact(new_item, function(saved_item) {
+        this.update_board_artifact_viewmodel(saved_item);
+        this.active_space_artifacts.push(saved_item);
+      }.bind(this));
+    },
+
+    create_artifact_via_embed_url: function(url) {
+      this.close_modal();
+      
+      var point = this.find_place_for_item(200,200);
+      var z = this.highest_z()+1;
+
+      var a = {
+        space_id: this.active_space._id,
+        mime: "image/png",
+        description: url,
+        state: "uploading",
+        x: point.x,
+        y: point.y,
+        w: 200,
+        h: 200,
+        z: z,
+        order: this.active_space_artifacts.length
+      }
+
+      var metadata = parse_link(url)
+      
+      if (!metadata) {
+        return;
+      }
+
+      if (metadata.type == "unknown") {
+        var enc_uri = encodeURIComponent(btoa(url));
+
+        a.meta = {
+          link_uri: url
+        }
+
+        if (this.guest_nickname) {
+          a.editor_name = this.guest_nickname;
+        }
+
+        // step 1: create placeholder
+        save_artifact(a, function(saved_a) {
+          this.update_board_artifact_viewmodel(saved_a);
+          this.active_space_artifacts.push(saved_a);
+
+          var thumb_uri = ENV.apiEndpoint + "/api/webgrabber/"+enc_uri;
+
+          // step 2: push payload_uri for processing
+          saved_a.state = "idle";
+          saved_a.payload_uri = thumb_uri;
+
+          saved_a.payload_thumbnail_web_uri = thumb_uri;
+          saved_a.payload_thumbnail_medium_uri = thumb_uri;
+          saved_a.payload_thumbnail_big_uri = thumb_uri;
+
+          save_artifact(saved_a, function(saved_a2) {
+            this.update_board_artifact_viewmodel(saved_a);
+          }.bind(this));
+
+        }.bind(this));
+
+        return;
+      }
+
+      var w = metadata.thumbnail_width || 200;
+      var h = metadata.thumbnail_height || 200;
+
+      if (w<200) w = 200;
+      if (h<200) h = 200;
+
+      if (metadata.provider_name == "soundcloud") {
+        w = 500;
+        h = 150;
+      }
+
+      a = _.extend(a, {
+        mime: "oembed/"+metadata.type+"-"+metadata.provider_name,
+        description: metadata.url || url,
+        //payload_uri: metadata.url || url,
+        payload_thumbnail_medium_uri: metadata.thumbnail_url,
+        payload_thumbnail_web_uri: metadata.thumbnail_url,
+        state: "idle",
+        title: metadata.title,
+        link_uri: metadata.url || url,
+        x: point.x - w/2,
+        y: point.y - h/2,
+        w: w,
+        h: h
+      });
+
+      if (this.guest_nickname) {
+        a.editor_name = this.guest_nickname;
+      }
+
+      save_artifact(a, function(saved_a) {
+        this.update_board_artifact_viewmodel(saved_a);
+        this.active_space_artifacts.push(saved_a);
+      }.bind(this));
+    },
+
+    create_artifact_via_payload_url: function(type, url) {
+      this.add_artifact(this.active_space, type, url, null);
+    },
+
+    handle_touch_select_background_image: function() {
+      $('#background-uploader').click();
+    },
+
+    handle_insert_image_url: function(url) {
+      if (!url || !url.length) {
+        $("#image_file_upload").click(); // redirect to file upload
+        return;
+      }
+
+      this.create_artifact_via_payload_url("image", url);
+      this.insert_image_url = "";
+      // this.opened_dialog = "none";
+    },
+
+    handle_insert_video_url: function(url) {
+
+      if (!url.length) {
+        $("#video_file_upload").click(); // redirect to file upload
+        return;
+      }
+
+      var object = parse_link(url);
+
+      if (object) {
+        this.create_artifact_via_embed_url(url);
+      } else {
+        this.create_artifact_via_payload_url("video", url);
+      }
+
+      this.insert_video_url = "";
+      //this.opened_dialog = "none";
+    },
+
+    handle_insert_audio_url: function(url) {
+
+      if (!url.length) {
+        $("#audio_file_upload").click(); // redirect to file upload
+        return;
+      }
+
+      var object = parse_link(url);
+
+      if (object) {
+        this.create_artifact_via_embed_url(url);
+      } else {
+        this.create_artifact_via_payload_url("audio", url);
+      }
+      this.insert_audio_url = "";
+    },
+
+    handle_generic_file_upload: function(evt) {
+      var files = evt.target.files;
+      this.opened_dialog = "none";
+
+      if (files && files.length) {
+        console.log("file: ",files[0]);
+
+        for (var i=0; i<files.length; i++) {
+          var file = files[i];
+          if (file.type === "application/pdf") {
+            var point = {x: 100, y: 100}; //fixme, center upload?
+            this.dropped_point = point;
+            this.pending_pdf_file = file;
+            this.activate_modal('pdfoptions');
+          } else {
+            this.create_artifact_via_upload(null, file, true);
+          }
+        }
+      }
+    },
+
+    handle_image_file_upload: function(evt) {
+      this.handle_generic_file_upload(evt);
+    },
+    handle_video_file_upload: function(evt) {
+      this.handle_generic_file_upload(evt);
+    },
+    handle_audio_file_upload: function(evt) {
+      this.handle_generic_file_upload(evt);
+    },
+
+    handle_section_background_upload: function(evt) {
+      var f = evt.target.files[0];
+      this.space_background_uploading = true;
+      save_space_background_file(this.active_space, f, function(space) {
+        this.active_space = space;
+        this.space_background_uploading = false;
+      }.bind(this));
+    },
+
+    remove_section_background: function() {
+      this.active_space.background_uri = null;
+      save_space(this.active_space);
+    },
+
+    show_toolbar_props: function() {
+      if (this.selection_metrics.count==0) return;
+      arts = this.selected_artifacts();
+      for (var i=0;i<arts.length; i++) {
+        if (arts[i].mime=="x-spacedeck/zone") return;
+      }
+      this.toolbar_props_in = true;
+    },
+    
+    hide_toolbar_props: function() {
+      this.toolbar_props_in = false;
+    },
+    
+    show_toolbar_artifacts: function(x,y) {
+      this.toolbar_artifacts_x = (x-175)+"px";
+      this.toolbar_artifacts_y = y+"px";
+      this.toolbar_artifacts_in = true;
+    },
+    
+    hide_toolbar_artifacts: function() {
+      this.toolbar_artifacts_in = false;
+    },
+
+    start_adding_artifact: function(evt) {
+      evt = fixup_touches(evt);
+      
+      // toggle
+      if (this.toolbar_artifacts_in) {
+        this.hide_toolbar_artifacts();
+        return;
+      }
+      this.show_toolbar_artifacts(evt.pageX,evt.pageY);
+    },
+
+    start_drawing_scribble: function(evt) {
+      this.hide_toolbar_artifacts();
+      this.active_tool = "scribble";
+      this.opened_dialog = "none";
+    },
+
+    start_drawing_arrow: function(evt) {
+      this.hide_toolbar_artifacts();
+      this.active_tool = "arrow";
+      this.opened_dialog = "none";
+    },
+
+    start_drawing_line: function(evt) {
+      this.hide_toolbar_artifacts();
+      this.active_tool = "line";
+      this.opened_dialog = "none";
+    },
+
+    adjust_bounds_zoom: function() {
+      if (!this.active_space) return;
+
+      this.bounds_zoom = this.viewport_zoom;
+
+      var eff_w = this.active_space.width*this.viewport_zoom;
+      var eff_h = this.active_space.height*this.viewport_zoom;
+
+      if (window.innerWidth>eff_w) {
+        // horizontal centering
+        this.bounds_margin_horiz = (window.innerWidth-eff_w)/2;
+      } else {
+        this.bounds_margin_horiz = 0;
+      }
+
+      if (window.innerHeight-80>eff_h) {
+        // horizontal centering
+        this.bounds_margin_vert = (window.innerHeight-eff_h)/2-80;
+      } else {
+        this.bounds_margin_vert = 0;
+      }
+    },
+
+    zoom_to_original: function() {
+      var old_zoom = this.viewport_zoom;
+      this.viewport_zoom = 1;
+      this.viewport_zoom_percent = parseInt(this.viewport_zoom*100);
+      this.adjust_bounds_zoom();
+      this.zoom_adjust_scroll(this.viewport_zoom/old_zoom);
+    },
+
+    zoom_to_fit: function() {
+      var er = this.enclosing_rect(this.active_space_artifacts);
+
+      if (!er) return;
+
+      var pad = 200;
+      er.x1-=pad;
+      er.y1-=pad-100;
+      er.x2+=pad;
+      er.y2+=pad+100;
+
+      this.zoom_to_rect(er, 1);
+    },
+
+    zoom_to_zone: function(z) {
+      if (!$("#space").length) return;
+
+      var er = this.enclosing_rect([z]);
+
+      var el = $("#space")[0];
+      var cur_r = {
+        x1: el.scrollLeft/this.viewport_zoom,
+        y1: el.scrollTop/this.viewport_zoom,
+        x2: (el.scrollLeft+window.innerWidth)/this.viewport_zoom,
+        y2: (el.scrollTop+window.innerHeight)/this.viewport_zoom
+      };
+      
+      var pad = 10;
+      er.x1-=pad;
+      er.y1-=pad;
+      er.x2+=pad;
+      er.y2+=pad;
+
+      if (!this.animation_running) {
+        this.animation_running = true;
+        this.animate_zoom_to_rect(er, 200, cur_r);
+
+        this.current_zone_idx = this.zones.indexOf(z);
+      }
+    },
+
+    zoom_to_rect: function(er, max_zoom) {
+      if (!$("#space").length) return;
+
+      var el = $("#space")[0];
+      var w = er.x2-er.x1;
+      var h = er.y2-er.y1;
+
+      if (w>h) {
+        this.viewport_zoom = window.innerWidth/w;
+
+        if (window.innerHeight < h*this.viewport_zoom) {
+          this.viewport_zoom = window.innerHeight/h;
+        }
+      } else {
+        this.viewport_zoom = window.innerHeight/h;
+
+        if (window.innerWidth < w*this.viewport_zoom) {
+          this.viewport_zoom = window.innerWidth/w;
+        }
+      }
+
+      if (max_zoom) {
+        if (this.viewport_zoom>max_zoom) this.viewport_zoom = max_zoom;
+      }
+
+      if (this.viewport_zoom<0.05) this.viewport_zoom = 0.05;
+
+      this.viewport_zoom_percent = parseInt(this.viewport_zoom*100);
+      this.adjust_bounds_zoom();
+
+      if (!el) return;
+
+      var animate = function() {
+        el.scrollTop = (er.y1+(h/2))*this.viewport_zoom-window.innerHeight/2;
+        el.scrollLeft = (er.x1+(w/2))*this.viewport_zoom-window.innerWidth/2;
+        this.handle_scroll();
+      }.bind(this);
+
+      if ("requestAnimationFrame" in window) {
+        window.requestAnimationFrame(animate);
+      } else {
+        animate();
+      }
+    },
+
+    animate_zoom_to_rect: function(target_r, duration, cur_r, elapsed) {
+      if (!$("#space").length) return;
+
+      var el = $("#space")[0];
+      var anim_res = 20;
+      if (!elapsed) elapsed = 0;
+      
+      if (duration>elapsed) {
+        window.setTimeout(function() {
+          this.animate_zoom_to_rect(target_r, duration, cur_r, elapsed+anim_res);
+        }.bind(this), anim_res);
+
+        /*var dx = (el.scrollLeft-ncx)/anim_res;
+        var dy = (el.scrollLeft-ncy)/anim_res;
+
+        el.scrollLeft += dx;
+        el.scrollTop += dy;*/
+
+        // interpolate
+        var dx1 = ((target_r.x1-cur_r.x1)/duration)*elapsed;
+        var dx2 = ((target_r.x2-cur_r.x2)/duration)*elapsed;
+        var dy1 = ((target_r.y1-cur_r.y1)/duration)*elapsed;
+        var dy2 = ((target_r.y2-cur_r.y2)/duration)*elapsed;
+
+        var step_r = {
+          x1: cur_r.x1+dx1,
+          x2: cur_r.x2+dx2,
+          y1: cur_r.y1+dy1,
+          y2: cur_r.y2+dy2
+        };
+
+        /*console.log("cur_r: ",cur_r);
+        console.log("target_r: ",target_r);
+        console.log("step_r: ",step_r);*/
+
+        this.zoom_to_rect(step_r);
+      } else {
+        // done
+        this.zoom_to_rect(target_r);
+        this.animation_running = false;
+      }
+    },
+
+    zoom_to_point: function(p,amount) {
+      var el = $("#space")[0];
+      
+      var sx = el.scrollLeft/this.viewport_zoom;
+      var sy = el.scrollTop/this.viewport_zoom;
+      var ww = window.innerWidth/(this.viewport_zoom);
+      var wh = window.innerHeight/(this.viewport_zoom);
+
+      var oxx = (p.x-(sx+ww/2))*amount;
+      var oyy = (p.y-(sy+wh/2))*amount;
+      var ox = -oxx;
+      var oy = -oyy;
+      
+      var r = {
+        x1: p.x-(ww/2)*amount + ox,
+        y1: p.y-(wh/2)*amount + oy,
+        x2: p.x+(ww/2)*amount + ox,
+        y2: p.y+(wh/2)*amount + oy
+      };
+
+      this.zoom_to_rect(r,2);
+    },
+
+    throttled_zoom_to_point: _.throttle(function(p,a){
+      this.zoom_to_point(p,a);
+    }, 50),
+
+    zoom_to_cursor: function(evt,amount) {
+      var point = this.cursor_point_to_space(evt);
+      this.throttled_zoom_to_point.bind(this)(point,amount);
+    },
+
+    zoom_adjust_scroll: function(f) {
+      var adjust_scroll = function() {
+        if (!$("#space").length) return;
+        if (!this.active_space || !this.active_space_loaded) return;
+        
+        var el = $("#space")[0];
+
+        var eff_w = this.active_space.width*this.viewport_zoom;
+        var eff_h = this.active_space.height*this.viewport_zoom;
+        
+        var sx = el.scrollLeft;
+        var sy = el.scrollTop;
+
+        var cx = window.innerWidth/2;
+        var cy = window.innerHeight/2;
+
+        var ncx = f*(sx+cx)-cx;
+        var ncy = f*(sy+cy)-cy;
+
+        if (eff_w<window.innerWidth) ncx = 0;
+
+        if (eff_h<window.innerHeight) ncy = 0;
+
+        el.scrollLeft = ncx;
+        el.scrollTop = ncy;
+
+        this.handle_scroll();
+      };
+
+      if ("requestAnimationFrame" in window) {
+        window.requestAnimationFrame(adjust_scroll.bind(this));
+      } else {
+        adjust_scroll();
+      }
+    },
+
+    zoom_in: function() {
+      if (!this.viewport_zoom) this.viewport_zoom = 1;
+      var old_zoom = this.viewport_zoom;
+
+      this.viewport_zoom *= 1.5;
+      if (this.viewport_zoom>=2) {
+        this.viewport_zoom = 2;
+      }
+      this.viewport_zoom_percent = parseInt(this.viewport_zoom*100);
+      this.adjust_bounds_zoom();
+
+      this.zoom_adjust_scroll(this.viewport_zoom/old_zoom);
+    },
+
+    zoom_out: function() {
+      if (!this.viewport_zoom) this.viewport_zoom = 1;
+      var old_zoom = this.viewport_zoom;
+
+      this.viewport_zoom /= 1.5;
+      if (this.viewport_zoom<0.05) {
+        this.viewport_zoom = 0.05;
+      }
+      this.viewport_zoom_percent = parseInt(this.viewport_zoom*100);
+      this.adjust_bounds_zoom();
+
+      this.zoom_adjust_scroll(this.viewport_zoom/old_zoom);
+    },
+
+    activate_pan_tool: function(evt) {
+      if (evt) {
+        evt.stopPropagation();
+        evt.preventDefault();
+      }
+      this.active_tool = "pan";
+
+      if (this.stop_pan_timeout) {
+        window.clearTimeout(this.stop_pan_timeout);
+      }
+      // pan exit hack
+      this.stop_pan_timeout = window.setTimeout(function () {
+        if (this.active_tool == "pan") {
+          this.active_tool = "pointer";
+        }
+      }.bind(this),500);
+    },
+
+    approve_pdf_upload: function(evt,approve_pdf_upload, mode){
+      this.close_modal();
+      
+      if(mode == "classic"){
+        this.create_artifact_via_upload(evt, this.pending_pdf_file, false);
+      }
+
+      if(mode == "grid") {
+        this.global_spinner = true;
+        save_pdf_file(this.active_space, this.dropped_point, this.pending_pdf_file, approve_pdf_upload,  function(createdArtifacts){
+
+          this.global_spinner = false;
+
+          _.each(createdArtifacts, function(new_artifact){
+            this.update_board_artifact_viewmodel(new_artifact);
+            this.active_space_artifacts.push(new_artifact)
+          }.bind(this));
+
+        }.bind(this), function(xhr) {
+          this.global_spinner = false;
+          alert("Error PDF ("+xhr.status+")");
+        }.bind(this));
+      }
+
+    },
+
+    handle_data_drop: function(evt) {
+      if (this.active_space_role=="viewer") {
+        return false;
+      }
+
+      var json = evt.dataTransfer.getData('application/json');
+      var dest_section = this.active_space;
+      var files = evt.dataTransfer.files;
+
+      if (files && files.length) {
+        for (var i=0; i<files.length; i++) {
+          var file = files[i];
+          if (file.type === "application/pdf") {
+            var point = this.cursor_point_to_space(evt);
+            this.dropped_point = point;
+            this.pending_pdf_file = file;
+            this.activate_modal('pdfoptions');
+
+          } else {
+            this.create_artifact_via_upload(evt, file, (files.length>1));
+          }
+        }
+
+      } else {
+        var json = evt.dataTransfer.getData('application/json');
+
+        if (json) {
+          var parsed = JSON.parse(json);
+          delete parsed._id;
+          parsed.space_id = this.active_space._id;
+
+          var w = 300;
+          var h = 200;
+          if (parsed.board && parsed.w && parsed.h) {
+            w = parsed.w;
+            h = parsed.h;
+          }
+
+          var point = this.cursor_point_to_space(evt);
+          point.x-=w/2;
+          point.y-=h/2;
+
+          parsed.board = {
+            x: point.x,
+            y: point.y,
+            w: w,
+            h: h,
+            z: 20
+          };
+
+          if (this.guest_nickname) {
+            parsed.editor_name = this.guest_nickname;
+          }
+
+          save_artifact(parsed, function(saved_a) {
+            this.update_board_artifact_viewmodel(saved_a);
+            this.active_space_artifacts.push(saved_a);
+          }.bind(this));
+          return;
+        }
+
+        var html = evt.dataTransfer.getData('text/html');
+        
+        if (html) {
+          var rx = /src="([^"]+)"/g;
+          var m = rx.exec(html);
+          if (m) {
+            this.add_artifact(this.active_space, "image", m[1], evt);
+          }
+        }
+      }
+    },
+
+    clear_search_results: function() {
+      this.image_search_results = [];
+      this.audio_search_results = [];
+      this.video_search_results = [];
+    },
+
+    download_selected_artifacts: function() {
+      var arts = this.selected_artifacts();
+      if (arts.length!=1) return;
+
+      if (arts[0].payload_uri) {
+        try {
+          window.open(arts[0].payload_uri);
+        } catch (e) {
+          // could not open window
+        }
+      }
+    }
+  }
+}

+ 960 - 0
public/javascripts/spacedeck_spaces.js

@@ -0,0 +1,960 @@
+/*
+  SpacedeckSpaces
+  This module contains functions dealing with Spaces UI.
+*/
+
+var SpacedeckSpaces = {
+
+  data: {
+    active_space: {advanced:{}},
+    active_space_loaded: false,
+    active_space_role: "viewer",
+    active_space_version_dirty: true,
+    active_space_messages: [],
+    active_space_memberships: [],
+    active_folder_history_items: [],
+    active_space_users: [],
+    active_space_artifacts: [],
+    active_space_path: [],
+    access_settings_space: null,
+    access_settings_memberships: [],
+    duplicate_folders: [],
+    duplicate_folder_id: "",
+    pending_pdf_files: [],
+
+    meta_visible: false,
+    meta_unseen: 0,
+
+    present_mode: false,
+    space_editing_title: false,
+    create_space_title: "",
+    folder_reverse: 1,
+    embedded: false,
+    remix_cta: "Create Reply",
+    publish_cta: "Publish",
+    remix_copying: true,
+    remix_style: "",
+    guest_signup_enabled: false,
+    space_embed_html: "",
+    share_base: location.origin,
+    share_base_url: location.origin+"/spaces/",
+    share_base_url_enc: encodeURIComponent(location.origin+"/spaces/"),
+    social_bar: true,
+    can_add_comment: false,
+
+    space_info_section: "access",
+    editors_section: "list",
+    selected_member: null,
+    invite_member_role: 'viewer',
+    invite_email_error: null,
+    invite_email: "",
+    invite_message: "",
+    active_join_link: "",
+    join_link_role: "viewer",
+
+    mouse_state: "idle",
+
+    // folders
+    active_folder: null,
+    folder_sorting: "updated_at",
+    
+    folder_spaces_filter: null,
+    active_path_length : 0,
+
+    space_comment: "",
+    folder_spaces_search: "",
+
+    // map of artifact IDs to medium rich text editor objects
+    medium_for_object: {},
+  },
+
+  methods: {
+    search_spaces: function() {
+      var query = this.folder_spaces_search;
+      load_spaces_search(query, function(spaces) {
+        this.active_profile_spaces = spaces;
+      }.bind(this));
+    },
+    guest_logout: function() {
+      if ("localStorage" in window && localStorage) {
+        delete localStorage['guest_nickname'];
+      }
+      this.guest_nickname = "";
+      location.reload();
+    },
+    ask_guestname: function(dft, cb) {
+      smoke.prompt(__('what_is_your_name', "Spacedeck") , function(content) {
+        if (!content || (content.length === 0)) {
+          this.ask_guestname(dft, cb);
+        } else {
+          this.guest_nickname = content;
+
+          if ("localStorage" in window && localStorage) {
+            try {
+              localStorage['guest_nickname'] = this.guest_nickname;
+            } catch(e) {
+              console.error(e);
+            }
+          }
+          if (cb) cb();
+        }
+      }.bind(this), {value: dft || "Guest "+parseInt(10000*Math.random()), ok: __("ok"), cancel: __("cancel")});
+    },
+    
+    load_space: function(space_id, on_success, on_error) {
+
+      console.log("load space: ", space_id);
+      this.folder_spaces_filter="";
+      this.folder_spaces_search="";
+
+      space_auth = get_query_param("spaceAuth");
+
+      var userReady = function() {
+        if (get_query_param("embedded")) {
+          this.embedded = true;
+          this.guest_signup_enabled = true;
+          if (get_query_param("publish_cta")) {
+            this.publish_cta = get_query_param("publish_cta");
+          }
+          if (get_query_param("nosocial")) {
+            this.social_bar = false;
+          }
+        }
+
+        if (get_query_param("confirm") && this.logged_in) {
+          var token = get_query_param("confirm");
+          confirm_user(this.user, token, function() {
+            this.redirect_to("/spaces/"+space_id+"?show_access=1");
+          }.bind(this), function() {
+            alert("An error occured confirming your email with the given token.");
+          });
+          return;
+        }
+
+        this.close_dropdown();
+
+        this.active_space_loaded = false;
+        this.viewport_zoom = 1;
+        this.viewport_zoom_percent = 100;
+
+        this.loading_space_id = space_id;
+        this.present_mode = false;
+        this.active_space_is_readonly = true;
+        this.opened_dialog = "none";
+        this.open_space_dialog = "none";
+
+        this.selected_artifacts_dict = {};
+        this.update_selection_metrics();
+
+        this.can_add_comment = false;
+
+        var is_home = false;
+        if (this.user) {
+          is_home = (space_id == this.user.home_folder_id);
+        }
+
+        document.title = "Loading… | Spacedeck";
+
+        load_space(space_id, function(space, role) {
+          document.title = space.name;
+
+          this.active_space_role = role || "viewer"; //via req header from backend
+
+          this.space_embed_html = "<iframe width=\"1024\" height=\"768\" seamless src=\""+ENV.webEndpoint+"/spaces/"+space._id+"?embedded=1\"></iframe>";
+
+          if (!is_home) {
+            load_members(space, function(members) {
+              this.active_space_memberships = members;
+            }.bind(this));
+          }
+
+          console.log("[websocket] auth start");
+
+          if (space.space_type == "folder") {
+            this.active_space = {advanced:{}};
+            document.title = "Spacedeck";
+
+            load_spaces(space_id, is_home, function(spaces) {
+              space.children = spaces;
+
+              this.loading_space_id = null;
+              this.active_profile_spaces = space.children;
+              this.active_folder = space;
+              this.access_settings_space = space;
+
+              this.auth_websocket(this.active_folder);
+
+              this.load_space_path(this.active_folder);
+
+              if (is_home) {
+                this.root_folder = space;
+              }
+
+              load_history(space, function(history) {
+                console.log("loaded digest", history);
+                this.active_folder_history_items = history;
+                this.meta_unseen = 0;
+
+                if ("localStorage" in window && localStorage) {
+                  var last_seen = parseInt(localStorage[this.meta_last_seen_key()], 10);
+                } else {
+                  var last_seen = 0;
+                }
+
+                for (var i=0; i<history.length; i++) {
+                  var item = history[i];
+                  var t = new Date(item.last_action).getTime();
+                  var my_own = false;
+                  if (item.users.length==1 && item.users[0]=="you") {
+                    my_own = true;
+                  }
+                  if (t>last_seen && !my_own) this.meta_unseen++;
+                }
+              }.bind(this));
+
+              this.active_view = "folders";
+            }.bind(this));
+
+            if ("localStorage" in window) {
+              var key = "folder_sorting_"+space_id;
+              var key2 = "folder_reverse_"+space_id;
+              if (localStorage[key] && localStorage[key2]) {
+                this.folder_sorting = localStorage[key];
+                this.folder_reverse = parseInt(localStorage[key2]);
+                console.log("loaded folder sorting: ",this.folder_sorting,this.folder_reverse);
+              }
+            }
+
+            // legacy fix
+            if (this.folder_sorting == "opened_at") {
+              this.folder_sorting = "name";
+            }
+
+          } else if (space.space_type == "space") {
+            this.artifacts = [];
+
+            this.loading_space_id = null;
+
+            document.title = space.name;
+
+            if (space_auth || this.logged_in) {
+              this.can_add_comment = true;
+            }
+
+            this.setup_watches();
+
+            load_artifacts(space._id, function(artifacts) {
+
+              // FIXME: how to cleanly handle this error?
+              if (!artifacts) {
+                artifacts = [];
+              }
+              
+              // FIXME: remove kludge
+              for (var i=0; i<artifacts.length; i++) {
+                this.update_board_artifact_viewmodel(artifacts[i]);
+              }
+              this.active_space_artifacts = artifacts;
+
+              this.$set("active_space", space);
+              this.active_space = space;
+
+              this.auth_websocket(this.active_space);
+
+              this.active_view = "space";
+              this.fixup_space_size();
+
+              if (space._id != this.active_space._id) {
+                this.present_mode = true;
+                this.active_space_is_readonly = true;
+              } else {
+                this.active_space_is_readonly = false;
+              }
+
+              this.discover_zones();
+
+              //window.setTimeout(function() {
+              //  this.zoom_to_fit();
+              //}.bind(this),10);
+
+              if (on_success) {
+                on_success();
+              }
+
+              this.active_space_loaded = true;
+              this.extract_properties_from_selection(); // populates zones etc
+              
+              load_comments(space._id, function(messages) {
+                if (!messages) messages = [];
+                
+                this.active_space_messages = messages;
+                this.refresh_space_comments();
+              }.bind(this), function(xhr) { console.error(xhr); });
+
+            }.bind(this));
+
+            if (this.active_space_role == "editor" || this.active_space_role == "admin") {
+              this.present_mode = false;
+              this.active_space_is_readonly = false;
+            }
+
+            // FIXME
+            this.active_join_link = "";
+            this.join_link_role = "viewer";
+
+            // FIXME
+            if (this.active_space_role == "admin") {
+              this.space_info_section="access";
+            } else if (this.active_space_role == "editor") {
+              //this.space_info_section="versions";
+            } else {
+              this.space_info_section="info";
+            }
+          }
+        }.bind(this), function(xhr) {
+
+          if (on_error) {
+            return on_error(xhr);
+          }
+
+          if (xhr.status == 403) {
+            if (!this.logged_in) {
+              this.redirect_to("/login?space_id="+space_id);
+            } else {
+              this.redirect_to("/");
+            }
+          } else {
+            this.redirect_to("/not_found");
+            console.error(xhr);
+          }
+        }.bind(this));
+
+      }.bind(this);
+      
+      var default_guest = "";
+      if (("localStorage" in window && localStorage) && localStorage['guest_nickname']) {
+        this.guest_nickname = localStorage['guest_nickname'];
+        default_guest = this.guest_nickname;
+        userReady();
+      }
+
+      if (space_auth) {
+        if (this.guest_nickname) {
+          userReady();
+        } else {
+          this.ask_guestname(default_guest, function() {
+            userReady();
+          });
+        }
+      } else {
+        this.guest_nickname = "";
+        userReady();
+      }
+    },
+
+    refresh_space_comments: function() {
+      this.meta_unseen = 0;
+      var messages = this.active_space_messages;
+      var last_seen = 0;
+
+      if ("localStorage" in window && localStorage) {
+        last_seen = parseInt(localStorage[this.meta_last_seen_key()], 10);
+      }
+
+      for (var i=0; i<messages.length; i++) {
+        var item = messages[i];
+        var t = new Date(item.updated_at).getTime();
+        var my_own = false;
+        if (this.user && this.user._id!=item.user_id && !item.editor_name) {
+          my_own = true;
+        }
+        if (t>last_seen && !my_own) this.meta_unseen++;
+      }
+    },
+
+    go_to_next_space: function() {
+      var space_ids = this.active_folder.children.map(function(s){return s._id});
+      var idx = space_ids.indexOf(this.active_space._id);
+      console.log("index: ",idx);
+
+      var cur_idx = idx;
+      var done = false;
+      while (!done) {
+        var next = this.active_folder.children[(idx+1)%space_ids.length];
+        if (next.space_type == "folder") {
+          done = false;
+          idx++;
+        } else {
+          done = true;
+        }
+        if (cur_idx == idx) done = true; // wraparound
+      }
+      this.load_space(next._id);
+    },
+
+    go_to_previous_space: function() {
+      var space_ids = this.active_folder.children.map(function(s){return s._id});
+      var idx = space_ids.indexOf(this.active_space._id);
+      console.log("index: ",idx);
+
+      var cur_idx = idx;
+      var done = false;
+      while (!done) {
+        var idx = (idx<1?space_ids.length:idx)-1;
+        var prev = this.active_folder.children[idx];
+        if (prev.space_type == "folder") {
+          done = false;
+          idx--;
+        } else {
+          done = true;
+        }
+        if (cur_idx == idx) done = true; // wraparound
+      }
+      this.load_space(prev._id);
+    },
+
+
+    filtered_folder_children: function(type){
+      var type = type || "space";
+      return _.filter(this.active_folder.children, function(s){
+        return s.space_type == type;
+      })
+    },
+
+    load_space_path: function(space) {
+      if (!space) return [];
+
+      load_space_path(space._id, function(path) {
+        this.active_space_path = path;
+      }.bind(this), function() { console.log("could not load folder path")});
+    },
+
+    is_active_space_role: function(rolename) {
+      if(!this.active_space) return false;
+      return this.active_space_role == rolename;
+    },
+
+    create_space: function(space_type) {
+      if (!this.active_folder) return;
+
+      this.close_modal();
+      this.folder_spaces_filter="";
+
+      if (!this.active_folder.children) {
+        this.active_folder.children = [];
+      }
+
+      if (!space_type) space_type = "space";
+
+      var s = {
+        name: space_type == "space" ? __("untitled_space") : __("untitled_folder") ,
+        artifacts: [],
+        space_type: space_type,
+        parent_space_id: this.active_folder._id
+      };
+
+      if (this.create_space_title.length) {
+        s.name = this.create_space_title;
+      }
+
+      save_space(s, function(saved_space) {
+        this.active_folder.children.push(saved_space);
+        if (space_type != "folder") {
+          this.redirect_to("/"+saved_space.space_type+"s/"+saved_space._id, function(succ) {
+          });
+        } else {
+          this.rename_folder(saved_space);
+        }
+      }.bind(this), function(xhr) { 
+        alert("Error: Could not create Space ("+xhr.status+").");
+      }.bind(this));
+
+    },
+
+    save_space: function(s) {
+      save_space(s);
+    },
+
+    create_space_version: function() {
+
+      if (!this.is_pro(this.user)) {
+        // pro feature
+        smoke.confirm(__("spacedeck_pro_ad_versions"), function(confirmed) {
+          if (confirmed) this.show_upgrade_modal();
+        }.bind(this));
+        return;
+      }
+
+      this.version_saving = true;
+      this.present_mode = false;
+
+      var s = this.active_space.draft_space;
+      console.log("create_space_version:", s);
+
+      duplicate_space(s, null, function(new_version_space) {
+        load_spaces(this.active_space._id, false, function(space) {
+          this.version_saving = false;
+          this.activate_space_version(space, space.draft_space);
+
+          alert("Version saved.");
+        }.bind(this));
+      }.bind(this), function(xhr){
+        console.error(xhr);
+      }.bind(this));
+    },
+
+    finalize_folder_profile_edit: function() {
+      save_space(this.active_folder, function(saved_space) {
+        this.close_modal();
+      }.bind(this));
+    },
+
+    finalize_space_profile_edit: function() {
+      save_space(this.active_space, function(saved_space) {
+        this.close_modal();
+      }.bind(this));
+    },
+
+    delete_space: function(space) {
+      smoke.confirm("Really delete "+space.name+"?", function(confirmed) {
+        if (!confirmed) return;
+        var idx = this.active_folder.children.indexOf(space);
+
+        delete_space(space, function() {
+          if (space.parent_space_id){
+            this.redirect_to("/folders/"+space.parent_space_id, function(succ) {});
+          } else {
+            this.redirect_to("/spaces", function(succ) {});
+          }
+
+          this.close_modal();
+
+          this.active_folder.children.splice(idx,1);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    duplicate_space: function(space) {
+      duplicate_space(space, null, function(new_space) {
+        //alert("Space duplicated.");
+        this.active_folder.children.push(new_space);
+      }.bind(this), function(xhr){
+        console.error(xhr);
+      }.bind(this));
+    },
+
+    remove_avatar: function(space) {
+      remove_avatar_file("space", space, function(s) {
+        this.active_space = s;
+      }.bind(this));
+    },
+
+    rename_space: function(space) {
+      this.close_dropdown();
+      if (space.space_type == "folder") return this.rename_folder(space);
+      smoke.prompt(__("new_space_title"), function(title) {
+        if (title && title.length) {
+          space.name = title;
+          save_space(space);
+        }
+      }.bind(this), {value: space.name});
+    },
+
+    rename_folder: function(folder) {
+      this.close_dropdown();
+
+      smoke.prompt(__("new_folder_title"), function(title) {
+        if (title && title.length) {
+          folder.name = title;
+          save_space(folder);
+        }
+      }.bind(this), {value: folder.name});
+    },
+
+    edit_space_title: function() {
+      this.close_dropdown();
+      if (this.active_space_role=="editor" || this.active_space_role=="admin") {
+        this.space_editing_title = true;
+        $("#space-title").focus();
+      }
+    },
+
+    save_space_title: function(name) {
+      this.active_space.name = name;
+      save_space(this.active_space, function() {
+        this.space_editing_title = false;
+      }.bind(this));
+    },
+
+    save_space_keydown: function($event) {
+      if ($event) {
+        if ($event.keyCode != 13) {
+          this.space_editing_title = true;
+          return;
+        }
+
+        $event.preventDefault();
+        $event.stopPropagation();
+        $event.target.blur();
+      }
+
+      save_space(this.active_space, function(updated_space) {
+        this.active_space.edit_slug = updated_space.edit_slug;
+        this.space_editing_title = false;
+      }.bind(this));
+    },
+
+    save_space_description: function($event) {
+      $event.preventDefault();
+      $event.stopPropagation();
+
+      var val = $event.target.innerText;
+      $event.target.blur();
+      this.active_space.description = val;
+      save_space(this.active_space);
+    },
+
+    save_space_domain: function($event) {
+      $event.preventDefault();
+      $event.stopPropagation();
+
+      var val = $event.target.innerText;
+      $event.target.blur();
+      this.active_space.domain = val;
+      save_space(this.active_space);
+    },
+
+    download_space: function() {
+      smoke.quiz(__("download_space"), function(e, test) {
+        if (e == "PDF") {
+          this.download_space_as_pdf(this.active_space);
+        } else if (e == "ZIP") {
+          this.download_space_as_zip(this.active_space);
+        }
+      }.bind(this), {
+        button_1: "PDF",
+        button_2: "ZIP",
+        button_cancel:__("cancel")
+      });
+
+    },
+
+    download_space_as_png: function(space) {
+      window.open(ENV.apiEndpoint + "/api/spaces/" + space._id + "/png");
+    },
+
+    download_space_as_pdf: function(space) {
+      this.global_spinner = true;
+      get_resource("/spaces/" + space._id + "/pdf", function(o) {
+        this.global_spinner = false;
+        location.href = o.url;
+      }.bind(this), function(xhr) {
+        this.global_spinner = false;
+        alert("PDF export problem (" + xhr.status + ").");
+      }.bind(this));
+    },
+
+    download_space_as_zip: function(space) {
+      this.global_spinner = true;
+
+      get_resource("/spaces/" + space._id + "/zip", function(o) {
+       
+        this.global_spinner = false; 
+        location.href = o.url;
+
+      }.bind(this), function(xhr) {
+        this.global_spinner = false;
+        alert("ZIP export problem (" + xhr.status + ").");
+      }.bind(this));
+    },
+    
+    download_space_as_list: function(space) {
+      this.global_spinner = true;
+      location.href = "/api/spaces/" + space._id + "/list";
+    },
+    
+    duplicate_space_into_folder: function() {
+      load_writable_folders( function(folders){
+        this.duplicate_folders = _.sortBy(folders, function (folder) { return folder.name; });
+      }.bind(this), function(xhr) { 
+        console.error(xhr); 
+      });
+    },
+
+    duplicate_folder_confirm: function() {
+      var folderId = this.duplicate_folder_id;
+      var idx = _.findIndex(this.duplicate_folders, function(s) { return s._id == folderId;});
+      if (idx<0) idx = 0;
+      var folder = this.duplicate_folders[idx];
+      console.log("df f",folder);
+      if (!folder) return;
+
+      duplicate_space(this.active_space, folder._id, function(new_space) {
+        
+        this.duplicate_folders = [];
+        this.duplicate_folder = null;
+
+        smoke.quiz(__("duplicate_success", this.active_space.name, folder.name), function(e, test){
+          if (e == __("goto_space", new_space.name)){
+            this.redirect_to("/spaces/" +  new_space._id);
+          }else if (e == __("goto_folder", folder.name)){
+            this.redirect_to("/folders/" +  folder._id);
+          }
+        }.bind(this), {
+          button_1: __("goto_space", new_space.name),
+          button_2: __("goto_folder", folder.name),
+          button_cancel:__("stay_here")
+        });
+
+      }.bind(this), function(xhr){
+        
+        console.error(xhr);
+        smoke.prompt("error: " + xhr.statusText);
+
+      }.bind(this));
+    },
+    
+    toggle_follow_mode: function() {
+      this.deselect();
+      this.follow_mode = !this.follow_mode;
+    },
+    
+    toggle_present_mode: function() {
+      this.deselect();
+      this.present_mode = !this.present_mode;
+      if (this.present_mode) {
+        //this.go_to_first_zone();
+      }
+    },
+
+    meta_last_seen_key: function() {
+      var seen_key = "meta-seen-";
+      if (this.active_view == 'space') {
+        if (!this.active_space) return "invalid";
+        seen_key += this.active_space._id;
+      } else if (this.active_view == 'folders') {
+        if (!this.active_folder) return "invalid";
+        seen_key += this.active_folder._id;
+      }
+      return seen_key;
+    },
+
+    toggle_meta: function() {
+      this.meta_visible = !this.meta_visible;
+      if (this.meta_visible) {
+        var seen_key = this.meta_last_seen_key();
+
+        if ("localStorage" in window && localStorage) {
+          localStorage[seen_key] = new Date().getTime();
+          console.log("seen_key: ",seen_key,localStorage[seen_key]);
+          this.meta_last_seen = localStorage[seen_key];
+        }
+        this.meta_unseen = 0;
+      }
+    },
+
+    toggle_space_access_mode: function() {
+      this.access_settings_space.access_mode = (this.access_settings_space.access_mode=="public")?"private":"public";
+      save_space(this.access_settings_space);
+    },
+
+    save_space_access_mode: function(evt) {
+      // FIXME really bad that i have to do this manually. why is the
+      // value not already updated when v-on="change" is fired?!
+      this.access_settings_space.access_mode = evt.currentTarget.value;
+      save_space(this.access_settings_space);
+    },
+
+    save_space_editors_locking: function(evt) {
+      // FIXME same issue as above
+      this.access_settings_space.editors_locking = evt.currentTarget.checked;
+      save_space(this.access_settings_space);
+    },
+
+    create_join_link: function() {
+      create_join_link(this.active_space._id, this.join_link_role, function(result) {
+        this.active_join_link = "https://"+location.host+"/invitations/"+result.code+"/accept";
+      }.bind(this));
+    },
+
+    delete_join_link: function() {
+      get_join_link(this.active_space._id, function(result) {
+        if (result && result.length) {
+          delete_join_link(result[result.length-1]._id, function() {
+            this.active_join_link = "";
+          }.bind(this));
+        }
+      }.bind(this));
+    },
+
+    invite_member: function(space, emails_text, txt, role) {
+      this.invite_email_error = null;
+
+      var emails = emails_text.split(",");
+      var displayed_success = false;
+
+      _.each(emails, function(email) {
+        email = email.trim();
+
+        if (!validateEmail(email)) {
+          this.invite_email_error = "Please enter a valid address."
+          return;
+        }
+
+        var m = {
+          email_invited: email,
+          personal_message: txt,
+          role: role
+        }
+
+        create_membership(space, m, function(m) {
+          this.access_settings_memberships.push(m);
+          console.log("membership created:", m);
+          this.editors_section="list";
+          
+          if (!displayed_success) {
+            displayed_success = true;
+            smoke.alert("Invitation(s) sent.");
+            this.invite_email = "";
+            this.invite_message = "";
+          }
+        }.bind(this), function(xhr){
+
+          text = JSON.stringify(xhr.responseText);
+          smoke.alert("Error: "+text);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    update_member: function(space, m, role) {
+      m.role = role;
+      save_membership(space, m, function() {
+        console.log("saved")
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+      }.bind(this));
+    },
+
+    // revoke
+    remove_member: function(space, m) {
+      delete_membership(space, m, function() {
+        this.access_settings_memberships.splice(this.access_settings_memberships.indexOf(m), 1);
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+      }.bind(this));
+    },
+
+    history_back: function() {
+      window.history.back();
+    },
+
+    create_space_comment: function(comment) {
+      if (!comment.length) return;
+
+      var data = {
+        space: this.active_space._id,
+        message: comment,
+        editor_name: this.guest_nickname,
+        user: this.user
+      };
+
+      save_comment(this.active_space._id, data, function(comment) {
+        console.log("comment saved: ",comment.created_at);
+        this.active_space_messages.push(comment);
+        this.space_comment = "";
+      }.bind(this), function(xhr){
+        console.error(xhr);
+      }.bind(this));
+    },
+
+    remove_space_comment: function(comment) {
+      delete_comment(this.active_space._id, comment._id, function() {
+        console.log("comment id:",comment._id);
+        this.active_space_messages = _.filter(this.active_space_messages, function(c){return c._id!=comment._id;});
+      }.bind(this), function(xhr){
+        console.error(xhr);
+      }.bind(this));
+    },
+    
+    emojified_comment: function(comment) {
+      return twemoji.parse(comment);
+    },
+
+    set_folder_sorting: function(key,reverse) {
+      this.folder_sorting = key;
+      this.folder_reverse = reverse?-1:1;
+
+      console.log(key, reverse);
+      
+      if ("localStorage" in window) {
+        localStorage["folder_sorting_"+this.active_folder._id] = this.folder_sorting;
+        localStorage["folder_reverse_"+this.active_folder._id] = this.folder_reverse;
+      }
+    },
+
+    activate_space_info_section: function(id) {
+      this.space_info_section = id;
+      this.editors_section = "list";
+
+      if (id == "versions") {
+        // load space versions
+        load_spaces(this.active_space._id, null, function(space_with_children) {
+          this.active_space.children = space_with_children.children;
+          console.log("loaded: ",space_with_children);
+        }.bind(this));
+      } else if (id == "info") {
+        // load replies
+      }
+    },
+
+    handle_folder_drop: function(evt, dest) {
+      try {
+        var source = JSON.parse(evt.dataTransfer.getData("application/json"));
+      } catch (e) {
+        return;
+      }
+      if (!source || !source._id || !source.parent_space_id || !dest._id) return;
+
+      if (source._id==dest._id) return;
+
+      if (dest.space_type!="folder") {
+        alert("Spaces can only be moved into folders.");
+        return;
+      }
+
+      source.parent_space_id = dest._id;
+      save_space(source, function() {
+        var idx = _.findIndex(this.active_folder.children, function(s) { return s._id == source._id;});
+        if (idx>=0) {
+          this.active_folder.children.splice(idx,1);
+          console.log("spliced: ",idx);
+        }
+      }.bind(this));
+    },
+
+    activate_access: function() {
+      this.activate_modal("access");
+      //this.meta_visible = false;
+
+      if (this.active_space._id) {
+        this.access_settings_space = this.active_space;
+      } else if (this.active_folder && this.active_folder._id) {
+        this.access_settings_space = this.active_folder;
+      } else {
+        return;
+      }
+      
+      this.access_settings_memberships = this.active_space_memberships;
+    },
+    close_access: function() {
+      this.close_modal();
+    },
+
+    show_offline_help: function() {
+      smoke.confirm(__('was_offline'), function(confirmed) {
+        if (!confirmed) return;
+        location.reload();
+      });
+    }
+  }
+}

+ 127 - 0
public/javascripts/spacedeck_teams.js

@@ -0,0 +1,127 @@
+/*
+  SpacedeckTeams
+  This module contains functions dealing with Teams.
+*/
+
+var SpacedeckTeams = {
+
+  data: {
+    team_members: [],
+    team_loading: false,
+    team_logo: "",
+    team_emails: "",
+    team_email_invited: false,
+    team_plan_calculation: "",
+  },
+
+  methods: {
+    is_admin: function(user) {
+      return _.filter(user.team.admins, function(admin_id) {
+        return admin_id == user._id;
+      }).length > 0;
+    },
+
+    calculate_team: function() {
+      this.team_plan_calculation = "";
+    },
+
+    load_team: function() {
+      if (this.user.team) {
+        load_resource("GET", "/teams/" + this.user.team._id + "/memberships", null, function(members) {
+          this.team_members = members;
+
+          this.calculate_team();
+        }.bind(this), function(xhr, textStatus, errorThrown) {
+          console.log(xhr, textStatus, errorThrown);
+        });
+      }
+    },
+
+    team_save: function() {
+      load_resource("PUT", "/teams/" + this.user.team._id , this.user.team, function(res, xhr) {
+        alert("Team updated.");
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+        alert("Could not update Team.");
+      });
+    },
+
+    team_update_member: function(m){
+      load_resource("PUT", "/teams/" + this.user.team._id + "/memberships/" + m._id, m, function(res, xhr) {
+        console.log("members updated");
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+      });
+    },
+
+    team_invite_members: function(emails) {
+      var emailList = emails.split(",");
+      for (_i = 0, _len = emailList.length; _i < _len; _i++) {
+        email = emailList[_i];
+        email = email.replace(new RegExp(" ", "g"), "").toLowerCase();
+
+        if (validateEmail(email)) {
+          var data = {
+            email: email
+          };
+
+          load_resource("POST", "/teams/" + this.user.team._id + "/memberships", data, function(res, xhr) {
+            
+            this.team_email_invited = true;
+            this.team_members.push(res);
+            var timeoutID = window.setTimeout(function(){
+              this.team_email_invited = false;
+            }.bind(this), 1000);
+      
+            this.team_emails = "";
+
+          }.bind(this), function(xhr, textStatus, errorThrown) {
+            console.log(xhr, textStatus, errorThrown);
+            this.team_invite_error = JSON.parse(xhr.responseText).error;
+
+          }.bind(this));
+        }
+
+      }
+    },
+    team_promote_member: function(m) {
+      load_resource("GET", "/teams/" + this.user.team._id + "/memberships/" + m._id + "/promote" , null, function(res, xhr) {
+        this.load_user(function() {
+          this.load_team();          
+        }.bind(this));
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+      });
+    },
+    team_demote_member: function(m) {
+      load_resource("GET", "/teams/" + this.user.team._id + "/memberships/" + m._id + "/demote" , null, function(res, xhr) {
+        this.load_user(function() {
+          this.load_team();          
+        }.bind(this));
+      }.bind(this), function(xhr) {
+        console.error(xhr);
+      });    
+    },
+    team_remove_member: function(m) {
+      if (confirm("Really delete this member?")) {
+        if (m.user_id && m.state === "active") {
+
+          load_resource("DELETE", "/users/" + m._id, null, function(res, xhr) {
+            var idx = this.team_members.indexOf(m);
+            this.team_members.splice(idx, 1);
+          }.bind(this), function(xhr) {
+            console.error(xhr);
+          });
+
+        } else {
+          load_resource("DELETE", "/teams/" + this.user.team._id + "/memberships/" + m._id, null, function(res, xhr) {
+            var idx = this.team_members.indexOf(m);
+            this.team_members.splice(idx, 1);
+          }.bind(this), function(xhr) {
+            console.error(xhr);
+          });
+        }
+      }
+    }
+  }
+}

+ 35 - 0
public/javascripts/spacedeck_updates.js

@@ -0,0 +1,35 @@
+/*
+  SpacedeckUpdates
+  This module contains functions dealing with Updates / Notifications.
+*/
+
+SpacedeckUpdates = {
+
+  user_notifications: [],
+  updates_items: [],
+
+  update_name_for_key: function(key) {
+
+    var updates_mapping = {
+      'space_like': "liked",
+      'space_comment': "commented in",
+      'space_follow': "is now following",
+      'space_publish': "published a new version of"
+    }
+
+    var name =  updates_mapping[key];
+    if(name) return name;
+    return key;
+  },
+
+  load_updates: function() {
+
+    load_notifications(this.user, function(notifications) { 
+      this.user_notifications = notifications;
+    });
+  },
+
+  activate_updates: function() {
+    $location.path("/updates");
+  }
+}

+ 299 - 0
public/javascripts/spacedeck_users.js

@@ -0,0 +1,299 @@
+/*
+  SpacedeckUsers
+  This module contains functions dealing with Users and Authentication.
+*/
+
+SpacedeckUsers = {
+  data: {
+    user_forms_email: "",
+    user_forms_name: "",
+    invitation_token: null,
+    login_email: "",
+    login_password: "",
+    signup_password: "",
+    signup_password_confirmation: "",
+    account_remove_error: null,
+    loading_user: false,
+    password_reset_confirm_error: "",
+    password_reset_error: "",
+    
+  },
+  methods:{
+    load_user: function(on_success, on_error) {
+      this.loading_user = true;
+
+      load_current_user(function(user) {
+        this.user = user;
+        this.loading_user = false;
+        this.logged_in = true;
+
+        if (on_success) {
+          on_success(user);
+        }
+
+        // see spacedeck_account.js
+        load_importables(this.user, function(files) {
+          this.importables = files;
+        }.bind(this));
+        
+      }.bind(this), function() {
+        // error
+        this.loading_user = false;
+        this.logout();
+
+        if (on_error) {
+          on_error();
+        }
+      }.bind(this));
+    },
+
+    finalize_login: function(session_token, on_success) {
+      this.load_user(function(user) {
+        if (this.invitation_token) {
+          accept_invitation(this.invitation_token, function(memberships){
+            this.redirect_to("/spaces/"+memberships.space_id);
+          }.bind(this), function(xhr){
+            console.error(xhr);
+            alert("Could not accept invitation. Maybe it was already accepted?");
+            this.redirect_to("/spaces");
+          }.bind(this));
+        } else {
+          if (on_success) {
+            on_success(this.user);
+          } else {
+            if (get_query_param("space_id") && get_query_param("space_id").length==24) {
+              this.redirect_to("/spaces/"+get_query_param("space_id"));
+            } else {
+              this.redirect_to("/spaces", function() {});
+            }
+          }
+        }
+      }.bind(this));
+    },
+
+    login_with_token: function(token) {
+      create_session_for_oauthtoken(token, function(session) {
+        this.session = session;
+        this.finalize_login(session.token);
+      }.bind(this), function(xhr){
+        // FIXME: handle error
+      }.bind(this));
+    },
+
+    login_submit: function(email, password, $event, on_success) {
+      this.loading_user = true;
+      this.login_error = null;
+
+      if ($event) {
+        $event.preventDefault();
+        $event.stopPropagation();
+      }
+
+      create_session(email, password, function(session) {
+        console.log("session: ", session);
+        this.loading_user = false;
+        this.session = session;
+        this.finalize_login(session.token, on_success);
+
+      }.bind(this), function(req) {
+        this.loading_user = false;
+        var msg = "";
+
+        if (req.status>=403) {
+          var msg = "error_unknown_email";
+        } else {
+          try {
+            var msg = "error_"+(JSON.parse(req.responseText).error);
+          } catch (e) {
+            var msg = (req.responseText||"Unknown Error.").replace(/,/g," ");
+          }
+        }
+        this.login_error = __(msg);
+
+      }.bind(this));
+    },
+
+    login_submit_modal: function(email, password) {
+      this.login_submit(email, password, null, function() {
+        location.reload();
+      });
+    },
+
+    signup_guest: function(on_success) {
+    },
+
+    signup_submit: function($event, name, email, password, password_confirmation, on_success) {
+      this.creating_user = true;
+      this.signup_error = null;
+
+      if (("localStorage" in window) && localStorage) {
+        localStorage["sd_api_token"] = null;
+      }
+      api_token = null;
+
+      if ($event) {
+        $event.preventDefault();
+        $event.stopPropagation();
+      }
+
+      create_user(name, email, password, password_confirmation, function(session) {
+        this.creating_user = false;
+        this.login_submit(email, password, null, on_success);
+      }.bind(this), function(req) {
+        this.creating_user = false;
+        try {
+          var msg = "error_"+(JSON.parse(req.responseText).error);
+        } catch (e) {
+          var msg = (req.responseText||"Unknown Error.").replace(/,/g," ");
+        }
+
+        var msg = __(msg);
+        this.signup_error = msg;
+      }.bind(this));
+    },
+
+    signup_submit_modal: function($event, name, email, password, password_confirmation) {
+      this.signup_submit($event, name, email, password, password_confirmation, function() {
+        alert("Success.");
+        location.reload();
+      });
+    },
+
+    password_reset_submit: function(evt, email) {
+      if (evt) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      }
+
+      this.password_reset_error = null;
+      this.password_reset_send = false;
+
+      if (email === undefined || email.length < 3) {
+        this.password_reset_error = "This is not a valid email address";
+        return;
+      }
+
+      create_password_reset(email, function(parsed,req) {
+        if(req.status==201) {
+          this.password_reset_send = true;
+        }
+      }.bind(this), function(req) {
+        console.log(req.status);
+        if (req.status==404) {
+          var msg = "error_unknown_email";
+        } else {
+          try {
+            var msg = "error_"+(JSON.parse(req.responseText).error);
+          } catch (e) {
+            var msg = (req.responseText||"Unknown Error.").replace(/,/g," ");
+          }
+        }
+        this.password_reset_error = __(msg);
+      }.bind(this));
+    },
+
+    password_reset_confirm: function(evt, password, password_confirmation) {
+      if (evt) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      }
+
+      this.password_reset_confirm_error = null;
+      this.password_reset_send = false;
+
+      if(password != password_confirmation) {
+        this.password_reset_confirm_error = "Passwords do not match.";
+        return;
+      }
+
+      if(password.length < 5) {
+        this.password_reset_confirm_error = "Password too short (must have at least 5 characters).";
+        return;
+      }
+
+      confirm_password_reset(password, this.reset_token, function(parsed,req) {
+        if(req.status==201){
+          this.active_view = "login";
+        }
+      }.bind(this), function(req) {
+        if (req.status==404) {
+          var msg = "user not found";
+        } else {
+          var msg = "error: " + req.statusText;
+        }
+        this.password_reset_confirm_error = msg;
+      }.bind(this));
+    },
+
+    logout: function() {
+      this.active_view="login";
+      this.logged_in = false;
+      delete_session(function() {
+
+        this.active_space = {advanced:{}};
+        this.active_space_loaded = false;
+        this.active_sidebar_item = "none";
+        this.sidebar_state = "closed";
+        this.loading_user = false;
+        api_token = null;
+        this.user = {};
+        this.active_content_type = "login";
+        this.redirect_to("/");
+
+      }.bind(this));
+    },
+
+    send_feedback: function(text) {
+      if (text.length>0) {
+        create_feedback(this.user, text, function(xhr) {
+          alert(__("feedback_sent"));
+          this.close_modal()
+        }.bind(this), function(xhr) {
+          console.error(xhr);
+        });
+      }
+    },
+
+    remove_account: function(password, reason) {
+      this.account_remove_error = null;
+
+      if (reason && reason.length && (reason.length > 1)) {
+        create_feedback(this.user, reason, function(xhr) {
+          console.log("feedback sent");
+        }, function(xhr){});
+      }
+
+      if (!password) {
+        this.account_remove_error = "Password not correct";
+        return;
+      }
+
+      delete_user(this.user, password, function(xhr) {
+        alert("Sorry to see you go. Goodbye!");
+        this.logout();
+      }.bind(this), function(xhr) {
+        this.account_remove_error = "Password not correct ("+xhr.status+")";
+      }.bind(this));
+    },
+
+    user_avatar_image: function(user) {
+      return user.avatar_thumb_uri;
+    },
+
+    user_initials: function(user) {
+      var parts = (user?(user.nickname||user.email):"anonymous").replace(/[^a-zA-Z]/g,' ').replace(/ +/g,' ').split(' ');
+      if (parts.length>1) {
+        return parts[0][0]+parts[1][0];
+      }
+      return parts[0].substring(0,2);
+    },
+
+    has_avatar_image: function(user) {
+      return !!(user && user.avatar_thumb_uri && user.avatar_thumb_uri.length>0);
+    },
+
+    is_pro: function(user) {
+      return true;
+    },
+  }
+}

+ 167 - 0
public/javascripts/spacedeck_vue.js

@@ -0,0 +1,167 @@
+
+function boot_spacedeck() {
+  console.log("booting...");
+  // custom directives
+
+  setup_directives();
+  setup_whiteboard_directives();
+  setup_exclusive_audio_video_playback();
+
+  var data = {
+    active_view: null,
+    online: true,
+    was_offline: false,
+    account: "profile",
+    logged_in: false,
+    guest_nickname: null,
+    user: {},
+
+    active_profile: null,
+    active_profile_spaces: [],
+    active_dropdown: "none",
+
+    creating_user: false,
+    signup_error: null,
+    login_error: null,
+    password_reset_send: false,
+    password_reset_error: null,
+    password_reset_email: null,
+    password_reset_confirm_error: null,
+    reset_token: null,
+
+    global_spinner: false
+  };
+
+  var methods = {
+    activate_dropdown: function(id, evt) {
+      if (this.active_dropdown == id) {
+        this.active_dropdown = "none";
+        return;
+      }
+      this.active_dropdown = id;
+    },
+
+    close_dropdown: function(evt) {
+      if (evt) {
+        if ($(evt.target).parents(".dropdown").length) {
+          return;
+        }
+      }
+
+      this.active_dropdown = "none";
+    },
+
+    translate: function() {
+      return i18n.t(arguments)
+    },
+  };
+
+  // mix in functions from all Spacedeck modules
+
+  methods = _.extend(methods, SpacedeckUsers.methods);
+  methods = _.extend(methods, SpacedeckWebsockets.methods);
+  methods = _.extend(methods, SpacedeckSpaces.methods);
+  methods = _.extend(methods, SpacedeckTeams.methods);
+  methods = _.extend(methods, SpacedeckBoardArtifacts);
+  methods = _.extend(methods, SpacedeckFormatting);
+  methods = _.extend(methods, SpacedeckSections.methods);
+  methods = _.extend(methods, SpacedeckAvatars.methods);
+  methods = _.extend(methods, SpacedeckModals.methods);
+  methods = _.extend(methods, SpacedeckAccount.methods);
+  methods = _.extend(methods, SpacedeckRoutes);
+
+  data = _.extend(data, SpacedeckUsers.data);
+  data = _.extend(data, SpacedeckAccount.data);
+  data = _.extend(data, SpacedeckWebsockets.data);
+  data = _.extend(data, SpacedeckSpaces.data);
+  data = _.extend(data, SpacedeckTeams.data);
+  data = _.extend(data, SpacedeckSections.data);
+  data = _.extend(data, SpacedeckAvatars.data);
+  data = _.extend(data, SpacedeckModals.data);
+
+  Vue.filter('select', function (array, key, operant, value) {
+      var res = _.filter(array, function(e){
+      var test = eval(e[key] + " " + operant + " " + value);
+      return test;
+    });
+    return res;
+  });
+
+  Vue.filter('date', function (value, format) {
+    var day = moment(value);
+    return day.format(format).replace("\'", "").replace("\'", "");
+  });
+
+  Vue.filter('exceptFilter', function (array, key) {
+    var filtered = _.filter(array, function(i){
+      return i[key]==undefined;
+    });
+    return filtered;
+  });
+
+  Vue.filter('size', function (array) {
+    return array.length;
+  });
+
+  Vue.filter('empty?', function (array) {
+    return array.length==0;
+  });
+
+  Vue.filter('urls_to_links', function (text) {
+    return urls_to_links(text);
+  });
+
+  window.spacedeck = new Vue({
+    el: "body",
+    data: data,
+    methods: methods
+  });
+
+  var lang = "en";
+
+  window.refreshLocale = function() {
+    if (spacedeck && spacedeck.user && spacedeck.user.preferences) {
+      lang = spacedeck.user.preferences.language || "en";
+    } else if (window.browser_lang) {
+      lang = window.browser_lang;
+    }
+  }
+
+  window.refreshLocale();
+  
+  i18n.init({ lng: lang, resStore: window.locales }, function(err, t) {
+    console.log("i18n initialized: "+lang);
+  });
+
+  window.__ = function() { 
+    var params = Array.prototype.slice.call(arguments);
+    params.shift();
+    window.refreshLocale();
+    return i18n.t(arguments[0], { postProcess: "sprintf", sprintf: params });
+  };
+
+  spacedeck.setup_section_module();
+  spacedeck.load_user(function() {
+    spacedeck.route();
+  },function() {
+    spacedeck.route();
+  });
+
+  window.addEventListener("paste", function(evt) {
+    if (evt.target.nodeName=="INPUT" || (evt.target.nodeName=="TEXTAREA" && evt.target.id!="clipboard-ta") || evt.target.contenteditable) {
+      // cancel
+      return;
+    }
+    if (spacedeck.active_space) {
+      spacedeck.handle_section_paste(evt);
+    }
+  });
+}
+
+document.addEventListener("DOMContentLoaded",function() {
+  window.smoke = smoke;
+  window.alert = smoke.alert;
+  
+  FastClick.attach(document.body);
+  boot_spacedeck();
+});

+ 267 - 0
public/javascripts/spacedeck_websockets.js

@@ -0,0 +1,267 @@
+
+SpacedeckWebsockets = {
+  data: {
+    users_online: {}
+  },
+  methods: {
+    handle_live_updates: function(msg) {
+      if (msg.model == "Space" && msg.object) {
+        if (msg.object.space_type == "space") {
+          if (this.active_space) {
+            if (this.active_space._id == msg.object._id) {
+              this.active_space = _.merge(this.active_space, msg.object);
+            }
+          }
+        }
+      }
+
+      if (msg.model == "Message") {
+        if (msg.action == "create" && msg.object) {
+          var new_message = msg.object;
+          if(this.active_space && this.active_space._id == new_message.space._id) {
+            this.active_space_messages.push(new_message);
+            this.refresh_space_comments();
+          } else console.log("message created in another space.");
+        }
+      }
+
+      if (msg.model == "Artifact") {
+        if (msg.action == "create" && msg.object) {
+          var new_artifact = msg.object;
+          if (this.active_space && this.active_space._id == new_artifact.space_id) {
+            var o = new_artifact;
+
+            if (o._id && !this.find_artifact_by_id(o._id)) {
+              this.update_board_artifact_viewmodel(new_artifact);
+              this.active_space_artifacts.push(new_artifact)
+            } else {
+              console.log("warning: got create on existing artifact.");
+              msg.action = "update"; // hackety hack!
+            }
+          } else console.log("artifact created in another space.");
+        }
+        else if (msg.action == "update" && msg.object) {
+          if (this.active_space) {
+            var o = msg.object;
+            if (o && o._id) {
+              var existing_artifact = this.find_artifact_by_id(o._id);
+              if (!existing_artifact) {
+                existing_artifact = o;
+              } else {
+                for (key in o) {
+                  existing_artifact[key] = o[key];
+                  this.update_board_artifact_viewmodel(existing_artifact);
+                }
+              }
+            }
+          }
+        }
+        else if (msg.action == "delete" && msg.object) {
+          if (this.active_space) {
+            var o = msg.object;
+            if (o._id){
+              var existing_artifact = this.find_artifact_by_id(o._id);
+              if (existing_artifact) {
+                var idx = this.active_space_artifacts.indexOf(existing_artifact);
+                this.active_space_artifacts.splice(idx, 1);
+              } else console.log("existing artifact to delete not found");
+            } else console.error("object without _id");
+          }
+        }
+      }
+    },
+
+    subscribe: function(space) {
+      if (this.websocket && this.websocket.readyState==1) {
+        this.websocket.send(JSON.stringify({action: "subscribe", space_id: space._id}));
+      } else {
+        console.error("socket not ready yet. (subscribe)");
+      }
+    },
+
+    is_member_online: function(space, member) {
+      if (!member.user) {
+        return false;
+      }
+
+      if (!this.users_online[space._id]) {
+        return false;
+      }
+
+      var isOnline = _.find(this.users_online[space._id], function(u) {
+        return (u._id == member.user._id);
+      });
+
+      return isOnline;
+    },
+
+    auth_websocket: function(space){
+      if (!this.websocket) {
+        this.init_websocket();
+      }
+
+      if (this.websocket && this.websocket.readyState==1) {
+        var token = "";
+        if (this.user) token = this.user.token;
+        var auth_params = {
+          action: "auth",
+          editor_auth: space_auth,
+          editor_name: this.guest_nickname,
+          auth_token: token,
+          space_id: space._id
+        };
+        console.log("[websocket] auth space");
+        this.websocket.send(JSON.stringify(auth_params));
+      }
+    },
+
+    websocket_send: function(msg) {
+      if (!this.websocket) return;
+      if (this.websocket.readyState!=1) return;
+
+      try {
+        this.websocket.send(JSON.stringify(msg));
+      } catch (e) {
+        // catch NS problems
+      }
+    },
+
+    init_websocket: function() {
+      if (this.websocket) this.websocket = null;
+
+      if (this.current_timeout) {
+        clearTimeout(this.current_timeout);
+        this.current_timeout = null;
+      }
+
+      try {
+        this.websocket = new WebSocket(ENV.websocketsEndpoint + "/socket");
+      } catch (e) {
+        console.log("[websocket] cannot establish websocket connection: ",e);
+        this.current_timeout = setTimeout(function() {
+          console.log("[websocket] reconnecting", e);
+          this.init_websocket();
+        }.bind(this),5000);
+      }
+
+      if (!this.websocket) {
+        console.log("[websocket] no websocket support?");
+        return;
+      }
+
+      this.websocket.onopen = function(evt) {
+        if (this.current_timeout) {
+          clearTimeout(this.current_timeout);
+          this.current_timeout = null;
+        }
+
+        if (this.active_space_loaded) {
+          this.auth_websocket(this.active_space);
+        }
+        this.online = true;
+
+      }.bind(this);
+
+      this.websocket.onclose = function(evt) {
+        if (!window._spacedeck_location_change) {
+          this.online = false;
+        }
+
+        if (!this.current_timeout) {
+          this.current_timeout = setTimeout(function() {
+            console.log("[websocket] onclose: reconnecting", evt);
+            this.init_websocket();
+          }.bind(this),5000);
+        }
+      }.bind(this);
+
+      this.websocket.onmessage = function(evt) {
+        this.online = true;
+
+        try {
+          var msg = JSON.parse(evt.data);
+        } catch (e) {
+          console.log("[websocket] malformed message: ",evt.data);
+          return;
+        }
+
+        if (msg.channel_id == channel_id) {
+          return;
+        }
+
+        if (msg.action == "cursor") {
+          this.handle_user_cursor_update(msg);
+        }
+        else if (msg.action == "viewport") {
+          this.handle_presenter_viewport_update(msg);
+        }
+        else if (msg.action == "media") {
+          this.handle_presenter_media_update(msg);
+        }
+
+        if (msg.action == "update" || msg.action == "create" || msg.action == "delete"){
+          this.handle_live_updates(msg);
+        }
+
+        if (msg.action == "init") {
+          channel_id = msg.channel_id;
+        }
+
+        if (msg.action == "auth_valid") {
+          if (this.active_space) {
+            this.subscribe(this.active_space);
+
+            if (this.unsaved_transactions()) {
+              console.log("[websockets-saver] found unsaved transactions, triggering save.");
+              this.process_artifact_save_queue();
+            }
+          }
+        }
+
+        if (msg.action == "subscription_valid") {
+          console.log("subscription_valid");
+        }
+
+        if (msg.action == "status_update") {
+          var spaceId = msg.space_id;
+          var users = msg.users;
+
+          // filter ourselves
+          if (this.user && this.user._id) {
+            users = _.filter(users, function(u) {
+              return (u && (u._id != this.user._id));
+            }.bind(this));
+          }
+          
+          users = _.filter(users, function(u) {
+            return (u && (u._id || u.nickname));
+          });
+          
+          this.users_online[spaceId] = users;
+          
+          if (this.active_space) {
+            if (this.active_space._id == spaceId) {
+              this.active_space_users = users;
+            }
+          }
+        }
+      }.bind(this);
+
+      this.websocket.onerror = function(evt) {
+        console.log("websocket.onerror:", evt);
+        if (!window._spacedeck_location_change) {
+          this.online = false;
+          this.was_offline = true;
+        }
+
+        if (!this.current_timeout) {
+          this.current_timeout = setTimeout(function() {
+            console.log("websocket.onerror: reconnecting", evt);
+            this.init_websocket();
+          }.bind(this),5000);
+        }
+
+      }.bind(this);
+    }
+  }
+}

+ 979 - 0
public/javascripts/spacedeck_whiteboard.js

@@ -0,0 +1,979 @@
+
+/*
+  Spacedeck Whiteboard Directive
+  This module registers a custom Vue directive that handles Whiteboard sections.
+*/
+
+function setup_whiteboard_directives() {
+  if ('ontouchstart' in window) {
+    var edown = "touchstart";
+    var emove = "touchmove";
+    var eup = "touchend";
+  } else {
+    var edown = "mousedown";
+    var emove = "mousemove";
+    var eup = "mouseup";
+  }
+
+  Vue.directive('sd-whiteboard', {
+    bind: function () {
+      var el = this.el;
+
+      $(el).on(edown, ".artifact", this.handle_mouse_down_artifact.bind(this));
+      $(el).on("dblclick", ".artifact", this.handle_double_click_artifact.bind(this));
+      $(el).on("keyup", ".artifact", this.handle_key_up_artifact.bind(this));
+      $(el).on("keydown", ".artifact", this.handle_key_down_artifact.bind(this));
+      $(el).bind(edown, this.handle_mouse_down_space.bind(this));
+      $(el).bind(emove, this.handle_mouse_move.bind(this));
+      $(el).bind(eup, this.handle_mouse_up_space.bind(this));
+      
+      $(el).bind("wheel", this.handle_wheel_space.bind(this));
+
+      $(document.body).bind("mouseleave", this.handle_mouse_leave.bind(this));
+
+      $(el).find(".handle.resize-nw").bind(edown, function(e){this.handle_transform_mouse_down(e,1,1)}.bind(this));
+      $(el).find(".handle.resize-n").bind(edown, function(e){this.handle_transform_mouse_down(e,0.5,1)}.bind(this));
+      $(el).find(".handle.resize-ne").bind(edown, function(e){this.handle_transform_mouse_down(e,0,1)}.bind(this));
+      $(el).find(".handle.resize-e").bind(edown, function(e){this.handle_transform_mouse_down(e,0,0.5)}.bind(this));
+      $(el).find(".handle.resize-se").bind(edown, function(e){this.handle_transform_mouse_down(e,0,0)}.bind(this));
+      $(el).find(".handle.resize-s").bind(edown, function(e){this.handle_transform_mouse_down(e,0.5,0)}.bind(this));
+      $(el).find(".handle.resize-sw").bind(edown, function(e){this.handle_transform_mouse_down(e,1,0)}.bind(this));
+      $(el).find(".handle.resize-w").bind(edown, function(e){this.handle_transform_mouse_down(e,1,0.5)}.bind(this));
+
+      $(el).find(".edge-handle.resize-n").bind(edown, function(e){this.handle_transform_mouse_down(e,0.5,1)}.bind(this));
+      $(el).find(".edge-handle.resize-s").bind(edown, function(e){this.handle_transform_mouse_down(e,0.5,0)}.bind(this));
+      $(el).find(".edge-handle.resize-e").bind(edown, function(e){this.handle_transform_mouse_down(e,0,0.5)}.bind(this));
+      $(el).find(".edge-handle.resize-w").bind(edown, function(e){this.handle_transform_mouse_down(e,1,0.5)}.bind(this));
+
+      $(el).on(edown, ".vector-handle", function(e){this.handle_vector_transform_mouse_down(e)}.bind(this));
+
+      var $scope = this.vm.$root;
+
+      this.space_zoom = 1;
+      this.artifacts_before_transaction = [];
+      $scope.active_tool = "pointer";
+    },
+    update: function () {
+    },
+    unbind: function () {
+      // do clean up work
+      // e.g. remove event listeners added in bind()
+
+      var el = this.el;
+      $(el).off(edown+" "+emove+" "+eup+" "+"keyup keydown mouseleave");
+      $(document.body).unbind("mouseleave");
+    },
+
+    handle_key_down_artifact: function(evt) {
+      var $scope = this.vm.$root;
+    },
+
+    handle_key_up_artifact: function(evt) {
+      var $scope = this.vm.$root;
+    },
+
+    handle_mouse_down_artifact: function(evt) {
+      var $scope = this.vm.$root;
+
+      if (!$scope.editing_artifact_id) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      }
+
+      var a = $scope.find_artifact_by_id(evt.currentTarget.id.replace("artifact-",""));
+
+      if ($scope.active_tool == "zoom") return;
+
+      if ($scope.active_tool == "eyedrop") {
+        var arts = $scope.selected_artifacts();
+        if (!$scope.is_selected(a) && arts.length > 0) {
+          // copy style from clicked artifact to selected artifacts
+          $scope.begin_transaction();
+
+          $scope.update_selected_artifacts(function(selected_artifact) {
+            selected_artifact.style = _.clone(a.style);
+          });
+
+          $scope.active_tool = "pointer";
+          return;
+        }
+      }
+
+      if ($scope.active_tool == "pan") {
+        this.start_pan(evt);
+        return;
+      }
+
+      if ($scope.active_tool == "pointer") {
+        if (!$scope.is_selected(a) || evt.shiftKey) {
+          this.select(evt,a);
+        }
+
+        // copy via alt+move
+        if (evt.altKey) {
+          a = $scope.clone_artifact(a);
+          this.select(evt,a);
+        }
+      }
+
+      $scope.begin_transaction();
+
+      var cursor = this.cursor_point_to_space(evt);
+      $scope.mouse_ox = cursor.x;
+      $scope.mouse_oy = cursor.y;
+      $scope.mouse_moved = false;
+      this.mouse_state = "move";
+      evt.stopPropagation();
+    },
+
+    handle_double_click_artifact: function(evt) {
+      var $scope = this.vm.$root;
+
+      var a = $scope.find_artifact_by_id(evt.currentTarget.id.replace("artifact-",""));
+      if (!a) return;
+      
+      if (a.payload_uri) {
+        $scope.download_selected_artifacts();
+      }
+
+      $scope.toggle_selected_artifact_editing(true);
+    },
+
+    handle_transform_mouse_down: function(evt,origin_x,origin_y) {
+      evt.stopPropagation();
+      evt.preventDefault();
+
+      var $scope = this.vm.$root;
+
+      $scope.begin_transaction();
+
+      var cursor = this.cursor_point_to_space(evt);
+      this.mouse_state = "transform";
+      $scope.mouse_ox = cursor.x;
+      $scope.mouse_oy = cursor.y;
+      $scope.transform_ox = origin_x;
+      $scope.transform_oy = origin_y;
+    },
+
+    handle_vector_transform_mouse_down: function(evt) {
+      evt.stopPropagation();
+      evt.preventDefault();
+
+      var $scope = this.vm.$root;
+
+      var idx = parseInt($(evt.currentTarget).attr("data-idx"));
+      $scope.selected_control_point_idx = idx;
+
+      $scope.begin_transaction();
+      var cursor = this.cursor_point_to_space(evt);
+      this.mouse_state = "vector_transform";
+      $scope.mouse_ox = cursor.x;
+      $scope.mouse_oy = cursor.y;
+      //$scope.transform_ox = origin_x;
+      //$scope.transform_oy = origin_y;
+    },
+
+    handle_wheel_space: function(evt) {
+      var $scope = this.vm.$root;
+
+      if (!evt.ctrlKey && !evt.shiftKey) return;
+      
+      evt.preventDefault();
+      evt.stopPropagation();
+      
+      var amount = 1;
+      var dy = evt.originalEvent.deltaY;
+      if (dy>0) {
+        amount=1.2;
+        if ($scope.viewport_zoom<=0.05) return false;
+      } else if (dy<0) {
+        amount=0.9;
+        if ($scope.viewport_zoom>=2) return false;
+      } else {
+        return false;
+      }
+      $scope.zoom_to_cursor(evt,amount);
+    },
+
+    handle_mouse_down_space: function(evt) {
+      if (evt.target != evt.currentTarget && !_.include(["wrapper"],evt.target.className)) return;
+
+      var $scope = this.vm.$root;
+
+      $scope.opened_dialog="none";
+
+      var cursor = this.cursor_point_to_space(evt);
+      $scope.mouse_ox = cursor.x;
+      $scope.mouse_oy = cursor.y;
+
+      if (evt.which == 2 || evt.buttons == 4) {
+        $scope.active_tool = "pan";
+      }
+      
+      if ($scope.active_tool=="note") {
+        this.deselect();
+        this.mouse_state = "transform";
+        $scope.mouse_state = this.mouse_state;
+        this.start_adding_note(evt);
+        return;
+
+      } else if ($scope.active_tool=="arrow") {
+        this.deselect();
+        this.mouse_state = "vector_transform";
+        $scope.mouse_state = this.mouse_state;
+        this.start_drawing_arrow(evt);
+        return;
+
+      } else if ($scope.active_tool=="line") {
+        this.deselect();
+        this.mouse_state = "vector_transform";
+        $scope.mouse_state = this.mouse_state;
+        this.start_drawing_line(evt);
+        return;
+
+      } else if ($scope.active_tool=="scribble") {
+        this.deselect();
+        this.mouse_state = "scribble";
+        $scope.mouse_state = this.mouse_state;
+        this.start_drawing_scribble(evt);
+        return;
+
+      } else if ($scope.active_tool=="zoom") {
+        if (evt.altKey) {
+          $scope.zoom_out();
+        } else {
+          $scope.zoom_in();
+        }
+        return;
+
+      } else if ($scope.active_tool=="pointer") {
+        this.mouse_state = "lasso";
+        this.start_lasso(evt);
+      } else if ($scope.active_tool=="zone") {
+        this.deselect();
+        this.mouse_state = "transform";
+        $scope.start_adding_zone(evt);
+        return;
+      } else if ($scope.active_tool=="image") {
+        this.deselect();
+        this.mouse_state = "transform";
+        $scope.start_adding_placeholder(evt);
+        return;
+      } else if ($scope.active_tool=="pan") {
+        this.start_pan(evt);
+        return;
+      }
+
+      if ($scope.selection_metrics.count>0) {
+        this._no_artifact_toolbar_this_round = true;
+      }
+
+      this.deselect();
+    },
+
+    start_pan: function(evt) {
+      var $scope = this.vm.$root;
+
+      el = $("#space")[0];
+      if (el) {
+        this.mouse_state = "pan";
+        this.old_panx = el.scrollLeft;
+        this.old_pany = el.scrollTop;
+      }
+      
+      var cursor = this.cursor_point_to_space(evt);
+      $scope.mouse_ox = cursor.x;
+      $scope.mouse_oy = cursor.y;
+      $scope.mouse_moved = false;
+    },
+
+    deselect: function() {
+      var $scope = this.vm.$root;
+      
+      $scope.deselect();
+    },
+
+    select: function(evt, a) {
+      var $scope = this.vm.$root;
+
+      $scope.select(evt, a);
+    },
+
+    multi_select: function(arts) {
+      var $scope = this.vm.$root;
+
+      $scope.multi_select(arts);
+    },
+
+    start_lasso: function(evt) {
+      var point = this.cursor_point_to_space(evt);
+      this.lasso = {
+        x: point.x,
+        y: point.y,
+        w: 0,
+        h: 0
+      }
+    },
+
+    rects_intersecting: function(r1,r2) {
+      if (!r1 || !r2) return false;
+      
+      if ( (r1.x+r1.w < r2.x)
+        || (r1.x > r2.x+r2.w)
+        || (r1.y+r1.h < r2.y)
+        || (r1.y > r2.y+r2.h) ) return false;
+      return true;
+    },
+
+    artifacts_in_rect: function(rect) {
+      if (!rect) return [];
+      
+      var $scope = this.vm.$root;
+
+      return _.filter($scope.active_space_artifacts, function(a) {
+        return this.rects_intersecting(a, rect);
+      }.bind(this));
+    },
+
+    abs_rect: function(rect) {
+      var res = {
+        x: rect.x,
+        y: rect.y,
+        w: Math.abs(rect.w),
+        h: Math.abs(rect.h)
+      }
+      if (rect.w<0) res.x+=rect.w;
+      if (rect.h<0) res.y+=rect.h;
+      return res;
+    },
+
+    lasso_style: function() {
+      var $scope = this.vm.$root;
+
+      if (!this.lasso) return "";
+      var lasso_scaled = {
+        x:this.lasso.x,
+        y:this.lasso.y,
+        w:this.lasso.w*$scope.viewport_zoom,
+        h:this.lasso.h*$scope.viewport_zoom
+      }
+      lasso_scaled = this.abs_rect(lasso_scaled);
+      lasso_scaled.x += $scope.bounds_margin_horiz;
+      lasso_scaled.y += $scope.bounds_margin_vert;
+
+      var s = "left:"  +lasso_scaled.x+"px;";
+      s +=    "top:"   +lasso_scaled.y+"px;";
+      s +=    "width:" +lasso_scaled.w+"px;";
+      s +=    "height:"+lasso_scaled.h+"px;";
+      s +=    "opacity: 1;";
+      return s;
+    },
+
+    render_lasso: function() {
+      if (!this.lasso) {
+        $("#lasso").hide();
+        return;
+      }
+
+      $("#lasso").attr("style", this.lasso_style());
+      $("#lasso").show();
+    },
+
+    cursor_point_to_space: function(evt) {
+      var $scope = this.vm.$root;
+      var offset = {left: 0, top: 0};
+
+      evt = fixup_touches(evt);
+
+      return {
+        x: (parseInt(evt.pageX) - parseInt(offset.left) - $scope.bounds_margin_horiz) / this.space_zoom,
+        y: (parseInt(evt.pageY) - parseInt(offset.top) - $scope.bounds_margin_vert)   / this.space_zoom
+      };
+    },
+
+    rect_to_points: function(rect) {
+      return [
+        {x:rect.x,y:rect.y},
+        {x:rect.x+rect.w,y:rect.y},
+        {x:rect.x,y:rect.y+rect.h},
+        {x:rect.x+rect.w,y:rect.y+rect.h}
+      ];
+    },
+
+    old_selection_rect: function() {
+      var $scope = this.vm.$root;
+
+      var selected = $scope.selected_artifacts().map(function(a){
+        return $scope.find_artifact_before_transaction(a);
+      }.bind(this));
+
+      return $scope.enclosing_rect(selected);
+    },
+
+    snap_point: function(x,y,snap_middle) {
+      var $scope = this.vm.$root;
+
+      var TOL = 8;
+      var dists = [];
+
+      if (snap_middle) {
+        dists.push([[x-window.innerWidth/2,Math.abs(y-window.innerHeight/2)],[x-window.innerWidth/2,Math.abs(y-window.innerHeight/2)]]);
+      }
+
+      if ($scope.grid_active) {
+        // snap to grid
+        var gw = $scope.grid.spacing/$scope.grid.subdivisions;
+        var gh = $scope.grid.spacing/$scope.grid.subdivisions;
+
+        var sx1 = parseInt(x/gw)*gw;
+        var sy1 = parseInt(y/gh)*gh;
+        var sx2 = (parseInt(x/gw)+1)*gw;
+        var sy2 = (parseInt(y/gh)+1)*gh;
+
+        dists = [[[Math.abs(sx1-x),sx1], [Math.abs(sy1-y),sy1]],
+                 [[Math.abs(sx2-x),sx2], [Math.abs(sy2-y),sy2]]];
+
+      } else {
+
+        // snap to other artifacts
+
+        dists = $scope.unselected_artifacts().map(function(a){
+
+          var r  = this.rect_to_points(a);
+
+          var xd1 = Math.abs(r[0].x-x);
+          var xd2 = Math.abs(r[1].x-x);
+          var xd3 = Math.abs(r[0].x+a.w/2 - x);
+
+          var yd1 = Math.abs(r[0].y-y);
+          var yd2 = Math.abs(r[2].y-y);
+          var yd3 = Math.abs(r[0].y+a.h/2 - y);
+
+          if (!snap_middle) {
+            if (xd2<xd1) {
+              var xd = xd2;
+              var sx = r[1].x;
+            } else {
+              var xd = xd1;
+              var sx = r[0].x;
+            }
+
+            if (yd2<yd1) {
+              var yd = yd2;
+              var sy = r[2].y;
+            } else {
+              var yd = yd1;
+              var sy = r[0].y;
+            }
+          }
+
+          if (snap_middle) {
+            var xd = xd3;
+            var sx = r[0].x+a.w/2;
+
+            var yd = yd3;
+            var sy = r[0].y+a.h/2;
+          }
+
+          return [[xd,sx],[yd,sy]];
+        }.bind(this));
+      }
+
+      // snap to space edges
+
+      dists.push([[Math.abs(x),0],[Math.abs(y),0]]);
+      //dists.push([[Math.abs(dims.width-x),dims.width],[Math.abs(dims.height-y),dims.height]]);
+
+      var unzipped = _.unzip(dists);
+      var xdists = _.sortBy(unzipped[0], function(pair) {return pair[0];});
+      var ydists = _.sortBy(unzipped[1], function(pair) {return pair[0];});
+
+      var results = {snapx:xdists[0], snapy:ydists[0]};
+      if (!xdists[0] || xdists[0][0]>TOL) {
+        results.snapx = [0,x];  // distance, coordinate
+      } else {
+        //$scope.snap_ruler_x = xdists[0][1];
+      }
+      if (!ydists[0] || ydists[0][0]>TOL) {
+        results.snapy = [0,y];
+      } else {
+        //$scope.snap_ruler_y = ydists[0][1];
+      }
+
+      return results;
+    },
+
+    offset_point_in_wrapper: function(point) {
+      var $scope = this.vm.$root;
+      var section_el = $(this.el)[0];
+      var z = $scope.viewport_zoom;
+
+      var pt = parseInt($("#space").css("padding-top"));
+
+      point.y=(point.y+section_el.scrollTop-pt)/z;
+      point.x=(point.x+section_el.scrollLeft)/z;
+
+      return point;
+    },
+
+    start_drawing_scribble: function(evt) {
+      evt.preventDefault();
+      evt.stopPropagation();
+
+      var $scope = this.vm.$root;
+      var point = this.offset_point_in_wrapper(this.cursor_point_to_space(evt));
+      var z = $scope.highest_z()+1;
+
+      $scope.deselect();
+
+      var a = {
+        space_id: $scope.active_space._id,
+        mime: "x-spacedeck/vector",
+        description: "",
+        control_points: [{dx:0,dy:0}],
+        x: point.x,
+        y: point.y,
+        z: z,
+        w: 64,
+        h: 64,
+        stroke_color: "#000000",
+        stroke: 2,
+        shape: "scribble"
+      };
+
+      $scope.save_artifact(a, function(saved_a) {
+        $scope.update_board_artifact_viewmodel(saved_a);
+        $scope.active_space_artifacts.push(saved_a);
+
+        this.select(evt,saved_a);
+        //$scope.tool_artifact = a;
+        $scope.transform_ox = 0;
+        $scope.transform_oy = 0;
+        $scope.begin_transaction();
+      }.bind(this));
+    },
+
+    start_drawing_arrow: function(evt) {
+
+      evt.preventDefault();
+      evt.stopPropagation();
+
+      var $scope = this.vm.$root;
+      var point = this.cursor_point_to_space(evt);
+      this.offset_point_in_wrapper(point);
+      var z = $scope.highest_z()+1;
+
+      var a = {
+        space_id: $scope.active_space._id,
+        mime: "x-spacedeck/vector",
+        description: "",
+        control_points: [{dx:0,dy:0},{dx:0,dy:0},{dx:0,dy:0}],
+        x: point.x,
+        y: point.y,
+        z: z,
+        w: 64,
+        h: 64,
+        stroke_color: "#000000",
+        stroke: 2,
+        shape: "arrow"
+      };
+
+      $scope.save_artifact(a, function(saved_a) {
+        $scope.update_board_artifact_viewmodel(saved_a);
+        $scope.active_space_artifacts.push(saved_a);
+        $scope.select(evt,a);
+        $scope.selected_control_point_idx = 1;
+        $scope.transform_ox = 0;
+        $scope.transform_oy = 0;
+        $scope.begin_transaction();
+      }.bind(this));
+    },
+
+    // FIXME: consolidate with arrow drawing?
+    start_drawing_line: function(evt) {
+      evt.preventDefault();
+      evt.stopPropagation();
+
+      var $scope = this.vm.$root;
+      var point = this.cursor_point_to_space(evt);
+      this.offset_point_in_wrapper(point);
+      var z = $scope.highest_z()+1;
+
+      var a = {
+        space_id: $scope.active_space._id,
+        mime: "x-spacedeck/vector",
+        description: "",
+        control_points: [{dx:0,dy:0},{dx:0,dy:0}],
+        x: point.x,
+        y: point.y,
+        z: z,
+        w: 64,
+        h: 64,
+        stroke_color: "#000000",
+        stroke: 2,
+        shape: "line"
+      };
+
+      $scope.save_artifact(a, function(saved_a) {
+        $scope.update_board_artifact_viewmodel(saved_a);
+        $scope.active_space_artifacts.push(saved_a);
+        $scope.select(evt,a);
+        $scope.selected_control_point_idx = 1;
+        $scope.transform_ox = 0;
+        $scope.transform_oy = 0;
+        $scope.begin_transaction();
+      }.bind(this));
+    },
+
+    snap_point_simple: function(point) {
+      var snapped = this.snap_point(point.x, point.y);
+      return {
+        x: snapped.snapx[1],
+        y: snapped.snapy[1]
+      }
+    },
+
+    handle_mouse_up_space: function(evt) {
+      var $scope = this.vm.$root;
+
+      evt.preventDefault();
+      
+      if (this.mouse_state == "lasso") {
+        var lasso_rect = this.abs_rect(this.offset_point_in_wrapper(this.lasso));
+        // convert to space coordinates
+
+        if (lasso_rect.w>0 && lasso_rect.h>0) {
+          var arts = this.artifacts_in_rect(lasso_rect);
+          this.multi_select(arts);
+        } else {
+          
+          if (this._no_artifact_toolbar_this_round) {
+            this._no_artifact_toolbar_this_round = false;
+          } else {
+            $scope.start_adding_artifact(evt);
+          }
+        }
+        this.lasso = null;
+        this.render_lasso();
+      }
+      else if (_.include(["transform","move","vector_transform","scribble"],this.mouse_state)) {
+        var ars = $scope.selected_artifacts();
+        
+        for (var i=0; i<ars.length; i++) {
+
+          if (_.include(["text","placeholder"],$scope.artifact_major_type(ars[i]))) {
+            // some types of artifact need a minimum size
+            if (ars[i].w<10) {
+              ars[i].w = 10;
+            }
+            if (ars[i].h<10) {
+              ars[i].h = 10;
+            }
+          }
+
+          //save_artifact(ars[i], null, $scope.display_saving_error);
+        }
+      }
+
+      if (this.mouse_state == "text_editor") {
+        return;
+      }
+
+      if (_.include(["zoom"], $scope.active_tool)) {
+        // tools that stay active after use
+        this.mouse_state = "idle";
+        $scope.mouse_state = this.mouse_state;
+        $scope.end_transaction();
+        $scope.deselect();
+        return;
+      }
+
+      this.mouse_state = "idle";
+      $scope.mouse_state = this.mouse_state;
+      this.lasso = null;
+      $scope.active_tool = "pointer";
+      $scope.end_transaction();
+      $scope.show_toolbar_props();
+
+    },
+
+    handle_mouse_leave: function(evt) {
+      var $scope = this.vm.$root;
+
+      this.mouse_state = "idle";
+      this.lasso = null;
+      $scope.active_tool = "pointer";
+      $scope.end_transaction();
+
+      this.render_lasso();
+    },
+
+    handle_mouse_move: function(evt) {
+      var $scope = this.vm.$root;
+      if (!$scope.active_space) return;
+
+      if (!$scope.editing_artifact_id) {
+        evt.preventDefault();
+        evt.stopPropagation();
+      }
+
+      $scope.handle_scroll();
+
+      var cursor = this.cursor_point_to_space(evt);
+      var dx = cursor.x - $scope.mouse_ox;
+      var dy = cursor.y - $scope.mouse_oy;
+      var dt = (new Date()).getTime() - this.last_mouse_move_time;
+      this.last_mouse_move_time = (new Date()).getTime();
+
+      var zoom = $scope.viewport_zoom||1;
+      if (zoom) {
+        dx/=zoom;
+        dy/=zoom;
+      }
+
+      // send cursor
+      if (dx>10 || dy>10 || dt>100) {
+        var name = "anonymous";
+        if ($scope.logged_in) {
+          name = $scope.user.nickname || $scope.user.email;
+        } else {
+          name = $scope.guest_nickname || "anonymous";
+        }
+
+        var cursor_msg = {
+          action: "cursor",
+          x: cursor.x/zoom,
+          y: cursor.y/zoom,
+          name: name,
+          id: $scope.user._id||name
+        };
+
+        $scope.websocket_send(cursor_msg);
+      }
+      
+      // side effects ftw!
+      $scope.snap_ruler_x = -1000;
+      $scope.snap_ruler_y = -1000;
+
+      $scope.mouse_moved = true;
+
+      $scope.transform_lock = evt.shiftKey;
+
+      if ($scope.transform_lock) {
+        if (this.mouse_state == "transform") {
+          // lock aspect is done in transform
+        } else {
+          // lock axis
+          if (Math.abs(dy)>Math.abs(dx)) {
+            dx = 0;
+          } else {
+            dy = 0;
+          }
+        }
+      }
+
+      if (this.mouse_state == "move") {
+        $scope.hide_toolbar_props();
+        
+        var snap_dx = 0;
+        var snap_dy = 0;
+
+        var selected = $scope.selected_artifacts();
+        var snap_edges = this.old_selection_rect();
+
+        if (selected.length && selected[0]._id==$scope.editing_artifact_id) {
+          // bail out of moving editable artifact
+          return;
+        }
+
+        if (snap_edges) {
+          var mx = snap_edges.x1 + (snap_edges.x2-snap_edges.x1)/2;
+          var my = snap_edges.y1 + (snap_edges.y2-snap_edges.y1)/2;
+          var snapped1 = this.snap_point(snap_edges.x1 + dx, snap_edges.y1 + dy, false);
+          var snapped2 = this.snap_point(snap_edges.x2 + dx, snap_edges.y2 + dy, false);
+          var snapped3 = this.snap_point(mx + dx, my + dy, true);
+
+          if (snapped3.snapx[0]>0) {
+            snap_dx = mx + dx - snapped3.snapx[1];
+          } else if (snapped2.snapx[0]>0) {
+            snap_dx = snap_edges.x2 + dx - snapped2.snapx[1];
+          } else {
+            snap_dx = snap_edges.x1 + dx - snapped1.snapx[1];
+          }
+
+          if (snapped3.snapy[0]>0) {
+            snap_dy = my + dy - snapped3.snapy[1];
+          } else if (snapped2.snapy[0]>0) {
+            snap_dy = snap_edges.y2 + dy - snapped2.snapy[1];
+          } else {
+            snap_dy = snap_edges.y1 + dy - snapped1.snapy[1];
+          }
+        }
+
+        $scope.update_selected_artifacts(function(a) {
+          var old_a = $scope.find_artifact_before_transaction(a);
+
+          if (old_a) {
+            return {
+              x: old_a.x + dx - snap_dx,
+              y: old_a.y + dy - snap_dy
+            };
+          } else {
+            // deleted?
+            return {};
+          }
+        }.bind(this));
+
+      } else if (this.mouse_state == "transform") {
+        var selected = $scope.selected_artifacts();
+        var edges = this.old_selection_rect();
+
+        if (!edges) {
+          this.mouse_state = "idle";
+          return;
+        }
+
+        $scope.hide_toolbar_props();
+        
+        var ew = (edges.x2-edges.x1);
+        var eh = (edges.y2-edges.y1);
+
+        var origin_x = edges.x1 + ew * $scope.transform_ox;
+        var origin_y = edges.y1 + eh * $scope.transform_oy;
+
+        // "leading point"
+        var lead_x = edges.x1 + ew * (1-$scope.transform_ox) - origin_x;
+        var lead_y = edges.y1 + eh * (1-$scope.transform_oy) - origin_y;
+
+        var lead_snapped = this.snap_point(origin_x + lead_x + dx, origin_y + lead_y + dy);
+        var moved_x = (lead_snapped.snapx[1] - origin_x);
+        var moved_y = (lead_snapped.snapy[1] - origin_y);
+
+        var scale_x = lead_x ? (moved_x)/lead_x : 1;
+        var scale_y = lead_y ? (moved_y)/lead_y : 1;
+        if (!$scope.transform_lock) scale_y = scale_x;
+
+        $scope.update_selected_artifacts(function(a) {
+          var old_a = $scope.find_artifact_before_transaction(a);
+
+          var x1 = origin_x + ((old_a.x - origin_x) * scale_x);
+          var y1 = origin_y + ((old_a.y - origin_y) * scale_y);
+          var x2 = origin_x + (((old_a.x + old_a.w) - origin_x) * scale_x);
+          var y2 = origin_y + (((old_a.y + old_a.h) - origin_y) * scale_y);
+
+          if (x1>x2) { var t = x1; x1 = x2; x2 = t; }
+          if (y1>y2) { var t = y1; y1 = y2; y2 = t; }
+
+          return {
+            x: x1,
+            y: y1,
+            w: x2 - x1,
+            h: y2 - y1
+          };
+        }.bind(this));
+
+      } else if (this.mouse_state == "lasso") {
+        this.lasso.w = dx;
+        this.lasso.h = dy;
+
+        this.render_lasso();
+
+      } else if (this.mouse_state == "vector_transform") {
+        $scope.hide_toolbar_props();
+        
+        var _this = this;
+        $scope.update_selected_artifacts(function(a) {
+          var old_a = $scope.find_artifact_before_transaction(a);
+
+          var control_points = _.cloneDeep(old_a.control_points);
+          var cp = control_points[$scope.selected_control_point_idx];
+
+          var snapped = _this.snap_point(old_a.x+cp.dx+dx, old_a.y+cp.dy+dy);
+          dx = snapped.snapx[1]-(old_a.x+cp.dx);
+          dy = snapped.snapy[1]-(old_a.y+cp.dy);
+
+          cp.dx += dx;
+          cp.dy += dy;
+
+          // special case for arrow's 3rd point
+          if (a.shape == "arrow" && $scope.selected_control_point_idx!=2) {
+            /*control_points[2].dx += dx/2;
+            control_points[2].dy += dy/2; */
+
+            control_points[2].dx = (control_points[0].dx+control_points[1].dx)/2;
+            control_points[2].dy = (control_points[0].dy+control_points[1].dy)/2;
+          }
+
+          return _this.normalize_control_points(control_points, old_a);
+        });
+
+      } else if (this.mouse_state == "scribble") {
+
+        $scope.update_selected_artifacts(function(a) {
+          var old_a = a;
+
+          var control_points = _.cloneDeep(old_a.control_points);
+          var offset = this.offset_point_in_wrapper({x:cursor.x,y:cursor.y});
+
+          control_points.push({
+            dx: offset.x-old_a.x,
+            dy: offset.y-old_a.y
+          });
+
+          return this.normalize_control_points(simplify_scribble_points(control_points), old_a);
+        }.bind(this));
+
+        var arts = $scope.selected_artifacts();
+
+        if (arts.length) {
+          $scope.update_board_artifact_viewmodel(arts[0]);
+        }
+      }
+      else if (this.mouse_state == "pan") {
+        if (!$("#space").length) return;
+        el = $("#space")[0];
+
+        el.scrollLeft = this.old_panx - dx*$scope.viewport_zoom;
+        el.scrollTop  = this.old_pany - dy*$scope.viewport_zoom;
+
+        $scope.handle_scroll();
+      }
+    },
+
+    normalize_control_points: function(control_points, artifact) {
+      var x1 = _.min(control_points,"dx").dx;
+      var y1 = _.min(control_points,"dy").dy;
+      var x2 = _.max(control_points,"dx").dx;
+      var y2 = _.max(control_points,"dy").dy;
+
+      var shiftx = -x1;
+      var shifty = -y1;
+
+      var shifted_cps = control_points.map(function(cp) {
+        return {
+          dx: cp.dx + shiftx,
+          dy: cp.dy + shifty
+        };
+      });
+
+      var w = Math.abs(x2 - x1);
+      var h = Math.abs(y2 - y1);
+
+      var bshiftx = 0;
+      var bshifty = 0;
+
+      if (artifact.w < 0) bshiftx = -artifact.w;
+      if (artifact.h < 0) bshifty = -artifact.h;
+
+      return {
+        x: artifact.x + bshiftx - shiftx,
+        y: artifact.y + bshifty - shifty,
+        w: w,
+        h: h,
+        z: artifact.z,
+        control_points: shifted_cps
+      };
+    }
+
+  });
+}

Some files were not shown because too many files changed in this diff