[
  {
    "path": ".babelrc",
    "content": "{\n  \"presets\": [\n    [\"env\", { \"modules\": false }],\n    \"stage-2\"\n  ],\n  \"plugins\": [\"transform-runtime\"],\n  \"comments\": false,\n  \"env\": {\n    \"test\": {\n      \"presets\": [\"env\", \"stage-2\"],\n      \"plugins\": [\"transform-es2015-modules-commonjs\", \"dynamic-import-node\"]\n    }\n  }\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": "node_modules\n.git\ndist\n.history\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".eslintignore",
    "content": "build/*.js\nconfig/*.js\nsrc/libs/*.js\n"
  },
  {
    "path": ".eslintrc.js",
    "content": "// http://eslint.org/docs/user-guide/configuring\n\nmodule.exports = {\n  root: true,\n  parser: 'babel-eslint',\n  parserOptions: {\n    sourceType: 'module'\n  },\n  env: {\n    browser: true,\n  },\n  extends: 'airbnb-base',\n  // required to lint *.vue files\n  plugins: [\n    'html'\n  ],\n  globals: {\n    \"NODE_ENV\": false,\n    \"VERSION\": false\n  },\n  // check if imports actually resolve\n  'settings': {\n    'import/resolver': {\n      'webpack': {\n        'config': 'build/webpack.base.conf.js'\n      }\n    }\n  },\n  // add your custom rules here\n  'rules': {\n    'no-param-reassign': [2, { 'props': false }],\n    // don't require .vue extension when importing\n    'import/extensions': ['error', 'always', {\n      'js': 'never',\n      'vue': 'never'\n    }],\n    // allow optionalDependencies\n    'import/no-extraneous-dependencies': ['error', {\n      'optionalDependencies': ['test/unit/index.js']\n    }],\n    // allow debugger during development\n    'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules/\ndist/\n.history\n.idea\nnpm-debug.log*\n.vscode\nstackedit_v4\nchrome-app/*.zip\n/test/unit/coverage/\n"
  },
  {
    "path": ".postcssrc.js",
    "content": "// https://github.com/michael-ciniawsky/postcss-load-config\n\nmodule.exports = {\n  \"plugins\": {\n    // to edit target browsers: use \"browserlist\" field in package.json\n    \"autoprefixer\": {}\n  }\n}\n"
  },
  {
    "path": ".stylelintrc",
    "content": "{\n  \"processors\": [\"stylelint-processor-html\"],\n  \"extends\": \"stylelint-config-standard\",\n  \"rules\": {\n    \"no-empty-source\": null\n  }\n}"
  },
  {
    "path": ".travis.yml",
    "content": "language: node_js\n\nnode_js:\n  - \"12\"\n\nservices:\n  - docker\n\nbefore_deploy:\n  # Run docker build\n  - docker build -t benweet/stackedit .\n  # Install Helm\n  - curl -SL -o /tmp/get_helm.sh https://git.io/get_helm.sh\n  - chmod 700 /tmp/get_helm.sh\n  - /tmp/get_helm.sh\n  - helm init --client-only\n\ndeploy:\n  provider: script\n  script: bash build/deploy.sh\n  on:\n    tags: true\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM benweet/stackedit-base\n\nRUN mkdir -p /opt/stackedit\nWORKDIR /opt/stackedit\n\nCOPY package*json /opt/stackedit/\nCOPY gulpfile.js /opt/stackedit/\nRUN npm install --unsafe-perm \\\n  && npm cache clean --force\nCOPY . /opt/stackedit\nENV NODE_ENV production\nRUN npm run build\n\nEXPOSE 8080\n\nCMD [ \"node\", \".\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright {yyyy} {name of copyright owner}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# StackEdit\n\n[![Build Status](https://img.shields.io/travis/benweet/stackedit.svg?style=flat)](https://travis-ci.org/benweet/stackedit) [![NPM version](https://img.shields.io/npm/v/stackedit.svg?style=flat)](https://www.npmjs.org/package/stackedit)\n\n> Full-featured, open-source Markdown editor based on PageDown, the Markdown library used by Stack Overflow and the other Stack Exchange sites.\n\nhttps://stackedit.io/\n\n### Ecosystem\n\n- [Chrome app](https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg)\n- NEW! Embed StackEdit in any website with [stackedit.js](https://github.com/benweet/stackedit.js)\n- NEW! [Chrome extension](https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha) that uses stackedit.js\n- [Community](https://community.stackedit.io/)\n\n### Build\n\n```bash\n# install dependencies\nnpm install\n\n# serve with hot reload at localhost:8080\nnpm start\n\n# build for production with minification\nnpm run build\n\n# build for production and view the bundle analyzer report\nnpm run build --report\n```\n\n### Deploy with Helm\n\nStackEdit Helm chart allows easy StackEdit deployment to any Kubernetes cluster.\nYou can use it to configure deployment with your existing ingress controller and cert-manager.\n\n```bash\n# Add the StackEdit Helm repository\nhelm repo add stackedit https://benweet.github.io/stackedit-charts/\n\n# Update your local Helm chart repository cache\nhelm repo update\n\n# Deploy StackEdit chart to your cluster\nhelm install --name stackedit stackedit/stackedit \\\n  --set dropboxAppKey=$DROPBOX_API_KEY \\\n  --set dropboxAppKeyFull=$DROPBOX_FULL_ACCESS_API_KEY \\\n  --set googleClientId=$GOOGLE_CLIENT_ID \\\n  --set googleApiKey=$GOOGLE_API_KEY \\\n  --set githubClientId=$GITHUB_CLIENT_ID \\\n  --set githubClientSecret=$GITHUB_CLIENT_SECRET \\\n  --set wordpressClientId=\\\"$WORDPRESS_CLIENT_ID\\\" \\\n  --set wordpressSecret=$WORDPRESS_CLIENT_SECRET\n```\n\nLater, to upgrade StackEdit to the latest version:\n\n```bash\nhelm repo update\nhelm upgrade stackedit stackedit/stackedit\n```\n\nIf you want to uninstall StackEdit:\n\n```bash\nhelm delete --purge stackedit\n```\n\nIf you want to use your existing ingress controller and cert-manager issuer:\n\n```bash\n# See https://docs.cert-manager.io/en/latest/tutorials/acme/quick-start/index.html\nhelm install --name stackedit stackedit/stackedit \\\n  --set dropboxAppKey=$DROPBOX_API_KEY \\\n  --set dropboxAppKeyFull=$DROPBOX_FULL_ACCESS_API_KEY \\\n  --set googleClientId=$GOOGLE_CLIENT_ID \\\n  --set googleApiKey=$GOOGLE_API_KEY \\\n  --set githubClientId=$GITHUB_CLIENT_ID \\\n  --set githubClientSecret=$GITHUB_CLIENT_SECRET \\\n  --set wordpressClientId=\\\"$WORDPRESS_CLIENT_ID\\\" \\\n  --set wordpressSecret=$WORDPRESS_CLIENT_SECRET \\\n  --set ingress.enabled=true \\\n  --set ingress.annotations.\"kubernetes\\.io/ingress\\.class\"=nginx \\\n  --set ingress.annotations.\"cert-manager\\.io/cluster-issuer\"=letsencrypt-prod \\\n  --set ingress.hosts[0].host=stackedit.example.com \\\n  --set ingress.hosts[0].paths[0]=/ \\\n  --set ingress.tls[0].secretName=stackedit-tls \\\n  --set ingress.tls[0].hosts[0]=stackedit.example.com\n```\n"
  },
  {
    "path": "build/build.js",
    "content": "require('./check-versions')()\n\nprocess.env.NODE_ENV = 'production'\n\nvar ora = require('ora')\nvar rm = require('rimraf')\nvar path = require('path')\nvar chalk = require('chalk')\nvar webpack = require('webpack')\nvar config = require('../config')\nvar webpackConfig = require('./webpack.prod.conf')\n\nvar spinner = ora('building for production...')\nspinner.start()\n\nrm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {\n  if (err) throw err\n  webpack(webpackConfig, function (err, stats) {\n    spinner.stop()\n    if (err) throw err\n    process.stdout.write(stats.toString({\n      colors: true,\n      modules: false,\n      children: false,\n      chunks: false,\n      chunkModules: false\n    }) + '\\n\\n')\n\n    console.log(chalk.cyan('  Build complete.\\n'))\n    console.log(chalk.yellow(\n      '  Tip: built files are meant to be served over an HTTP server.\\n' +\n      '  Opening index.html over file:// won\\'t work.\\n'\n    ))\n  })\n})\n"
  },
  {
    "path": "build/check-versions.js",
    "content": "var chalk = require('chalk')\nvar semver = require('semver')\nvar packageConfig = require('../package.json')\nvar shell = require('shelljs')\nfunction exec (cmd) {\n  return require('child_process').execSync(cmd).toString().trim()\n}\n\nvar versionRequirements = [\n  {\n    name: 'node',\n    currentVersion: semver.clean(process.version),\n    versionRequirement: packageConfig.engines.node\n  },\n]\n\nif (shell.which('npm')) {\n  versionRequirements.push({\n    name: 'npm',\n    currentVersion: exec('npm --version'),\n    versionRequirement: packageConfig.engines.npm\n  })\n}\n\nmodule.exports = function () {\n  var warnings = []\n  for (var i = 0; i < versionRequirements.length; i++) {\n    var mod = versionRequirements[i]\n    if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {\n      warnings.push(mod.name + ': ' +\n        chalk.red(mod.currentVersion) + ' should be ' +\n        chalk.green(mod.versionRequirement)\n      )\n    }\n  }\n\n  if (warnings.length) {\n    console.log('')\n    console.log(chalk.yellow('To use this template, you must update following to modules:'))\n    console.log()\n    for (var i = 0; i < warnings.length; i++) {\n      var warning = warnings[i]\n      console.log('  ' + warning)\n    }\n    console.log()\n    process.exit(1)\n  }\n}\n"
  },
  {
    "path": "build/deploy.sh",
    "content": "#!/bin/bash\nset -e\n\n# Tag and push docker image\ndocker login -u benweet -p \"$DOCKER_PASSWORD\"\ndocker tag benweet/stackedit \"benweet/stackedit:$TRAVIS_TAG\"\ndocker push benweet/stackedit:$TRAVIS_TAG\ndocker tag benweet/stackedit:$TRAVIS_TAG benweet/stackedit:latest\ndocker push benweet/stackedit:latest\n\n# Build the chart\ncd \"$TRAVIS_BUILD_DIR\"\nnpm run chart\n\n# Add chart to helm repository\ngit clone --branch master \"https://benweet:$GITHUB_TOKEN@github.com/benweet/stackedit-charts.git\" /tmp/charts\ncd /tmp/charts\nhelm package \"$TRAVIS_BUILD_DIR/dist/stackedit\"\nhelm repo index --url https://benweet.github.io/stackedit-charts/ .\ngit config user.name \"Benoit Schweblin\"\ngit config user.email \"benoit.schweblin@gmail.com\"\ngit add .\ngit commit -m \"Added $TRAVIS_TAG\"\ngit push origin master\n"
  },
  {
    "path": "build/dev-client.js",
    "content": "/* eslint-disable */\nrequire('eventsource-polyfill')\nvar hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true')\n\nhotClient.subscribe(function (event) {\n  if (event.action === 'reload') {\n    window.location.reload()\n  }\n})\n"
  },
  {
    "path": "build/dev-server.js",
    "content": "require('./check-versions')()\n\nvar config = require('../config')\nObject.keys(config.dev.env).forEach((key) => {\n  if (!process.env[key]) {\n    process.env[key] = JSON.parse(config.dev.env[key]);\n  }\n});\n\nvar opn = require('opn')\nvar path = require('path')\nvar express = require('express')\nvar webpack = require('webpack')\nvar proxyMiddleware = require('http-proxy-middleware')\nvar webpackConfig = require('./webpack.dev.conf')\n\n// default port where dev server listens for incoming traffic\nvar port = process.env.PORT || config.dev.port\n// automatically open browser, if not set will be false\nvar autoOpenBrowser = !!config.dev.autoOpenBrowser\n// Define HTTP proxies to your custom API backend\n// https://github.com/chimurai/http-proxy-middleware\nvar proxyTable = config.dev.proxyTable\n\nvar app = express()\nvar compiler = webpack(webpackConfig)\n\n// StackEdit custom middlewares\nrequire('../server')(app);\n\nvar devMiddleware = require('webpack-dev-middleware')(compiler, {\n  publicPath: webpackConfig.output.publicPath,\n  quiet: true\n})\n\nvar hotMiddleware = require('webpack-hot-middleware')(compiler, {\n  log: () => {}\n})\n// force page reload when html-webpack-plugin template changes\ncompiler.plugin('compilation', function (compilation) {\n  compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {\n    hotMiddleware.publish({ action: 'reload' })\n    cb()\n  })\n})\n\n// proxy api requests\nObject.keys(proxyTable).forEach(function (context) {\n  var options = proxyTable[context]\n  if (typeof options === 'string') {\n    options = { target: options }\n  }\n  app.use(proxyMiddleware(options.filter || context, options))\n})\n\n// handle fallback for HTML5 history API\napp.use(require('connect-history-api-fallback')())\n\n// serve webpack bundle output\napp.use(devMiddleware)\n\n// enable hot-reload and state-preserving\n// compilation error display\napp.use(hotMiddleware)\n\n// serve pure static assets\nvar staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)\napp.use(staticPath, express.static('./static'))\n\nvar uri = 'http://localhost:' + port\n\nvar _resolve\nvar readyPromise = new Promise(resolve => {\n  _resolve = resolve\n})\n\nconsole.log('> Starting dev server...')\ndevMiddleware.waitUntilValid(() => {\n  console.log('> Listening at ' + uri + '\\n')\n  // when env is testing, don't need open it\n  if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {\n    opn(uri)\n  }\n  _resolve()\n})\n\nvar server = app.listen(port)\n\nmodule.exports = {\n  ready: readyPromise,\n  close: () => {\n    server.close()\n  }\n}\n"
  },
  {
    "path": "build/utils.js",
    "content": "var path = require('path')\nvar config = require('../config')\nvar ExtractTextPlugin = require('extract-text-webpack-plugin')\n\nexports.assetsPath = function (_path) {\n  var assetsSubDirectory = process.env.NODE_ENV === 'production'\n    ? config.build.assetsSubDirectory\n    : config.dev.assetsSubDirectory\n  return path.posix.join(assetsSubDirectory, _path)\n}\n\nexports.cssLoaders = function (options) {\n  options = options || {}\n\n  var cssLoader = {\n    loader: 'css-loader',\n    options: {\n      minimize: process.env.NODE_ENV === 'production',\n      sourceMap: options.sourceMap\n    }\n  }\n\n  // generate loader string to be used with extract text plugin\n  function generateLoaders (loader, loaderOptions) {\n    var loaders = [cssLoader]\n    if (loader) {\n      loaders.push({\n        loader: loader + '-loader',\n        options: Object.assign({}, loaderOptions, {\n          sourceMap: options.sourceMap\n        })\n      })\n    }\n\n    // Extract CSS when that option is specified\n    // (which is the case during production build)\n    if (options.extract) {\n      return ExtractTextPlugin.extract({\n        use: loaders,\n        fallback: 'vue-style-loader'\n      })\n    } else {\n      return ['vue-style-loader'].concat(loaders)\n    }\n  }\n\n  // https://vue-loader.vuejs.org/en/configurations/extract-css.html\n  return {\n    css: generateLoaders(),\n    postcss: generateLoaders(),\n    less: generateLoaders('less'),\n    sass: generateLoaders('sass', { indentedSyntax: true }),\n    scss: generateLoaders('sass'),\n    stylus: generateLoaders('stylus'),\n    styl: generateLoaders('stylus')\n  }\n}\n\n// Generate loaders for standalone style files (outside of .vue)\nexports.styleLoaders = function (options) {\n  var output = []\n  var loaders = exports.cssLoaders(options)\n  for (var extension in loaders) {\n    var loader = loaders[extension]\n    output.push({\n      test: new RegExp('\\\\.' + extension + '$'),\n      use: loader\n    })\n  }\n  return output\n}\n"
  },
  {
    "path": "build/vue-loader.conf.js",
    "content": "var utils = require('./utils')\nvar config = require('../config')\nvar isProduction = process.env.NODE_ENV === 'production'\n\nmodule.exports = {\n  loaders: utils.cssLoaders({\n    sourceMap: isProduction\n      ? config.build.productionSourceMap\n      : config.dev.cssSourceMap,\n    extract: isProduction\n  })\n}\n"
  },
  {
    "path": "build/webpack.base.conf.js",
    "content": "var path = require('path')\nvar webpack = require('webpack')\nvar utils = require('./utils')\nvar config = require('../config')\nvar VueLoaderPlugin = require('vue-loader/lib/plugin')\nvar vueLoaderConfig = require('./vue-loader.conf')\nvar StylelintPlugin = require('stylelint-webpack-plugin')\n\nfunction resolve (dir) {\n  return path.join(__dirname, '..', dir)\n}\n\nmodule.exports = {\n  entry: {\n    app: './src/'\n  },\n  node: {\n    // For mermaid\n    fs: 'empty' // jison generated code requires 'fs'\n  },\n  output: {\n    path: config.build.assetsRoot,\n    filename: '[name].js',\n    publicPath: process.env.NODE_ENV === 'production'\n      ? config.build.assetsPublicPath\n      : config.dev.assetsPublicPath\n  },\n  resolve: {\n    extensions: ['.js', '.vue', '.json'],\n    alias: {\n      '@': resolve('src')\n    }\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.(js|vue)$/,\n        loader: 'eslint-loader',\n        enforce: 'pre',\n        include: [resolve('src'), resolve('test')],\n        options: {\n          formatter: require('eslint-friendly-formatter')\n        }\n      },\n      {\n        test: /\\.vue$/,\n        loader: 'vue-loader',\n        options: vueLoaderConfig\n      },\n      // We can't pass graphlibrary to babel\n      {\n        test: /\\.js$/,\n        loader: 'string-replace-loader',\n        include: [\n          resolve('node_modules/graphlibrary')\n        ],\n        options: {\n          search: '^\\\\s*(?:let|const) ',\n          replace: 'var ',\n          flags: 'gm'\n        }\n      },\n      {\n        test: /\\.js$/,\n        loader: 'babel-loader',\n        include: [\n          resolve('src'),\n          resolve('test'),\n          resolve('node_modules/mermaid')\n        ],\n        exclude: [\n          resolve('node_modules/mermaid/src/diagrams/class/parser'),\n          resolve('node_modules/mermaid/src/diagrams/flowchart/parser'),\n          resolve('node_modules/mermaid/src/diagrams/gantt/parser'),\n          resolve('node_modules/mermaid/src/diagrams/git/parser'),\n          resolve('node_modules/mermaid/src/diagrams/sequence/parser')\n        ],\n      },\n      {\n        test: /\\.(png|jpe?g|gif|svg)(\\?.*)?$/,\n        loader: 'url-loader',\n        options: {\n          limit: 10000,\n          name: utils.assetsPath('img/[name].[hash:7].[ext]')\n        }\n      },\n      {\n        test: /\\.(ttf|eot|otf|woff2?)(\\?.*)?$/,\n        loader: 'file-loader',\n        options: {\n          name: utils.assetsPath('fonts/[name].[hash:7].[ext]')\n        }\n      },\n      {\n        test: /\\.(md|yml|html)$/,\n        loader: 'raw-loader'\n      }\n    ]\n  },\n  plugins: [\n    new VueLoaderPlugin(),\n    new StylelintPlugin({\n      files: ['**/*.vue', '**/*.scss']\n    }),\n    new webpack.DefinePlugin({\n      VERSION: JSON.stringify(require('../package.json').version)\n    })\n  ]\n}\n"
  },
  {
    "path": "build/webpack.dev.conf.js",
    "content": "var utils = require('./utils')\nvar webpack = require('webpack')\nvar config = require('../config')\nvar merge = require('webpack-merge')\nvar baseWebpackConfig = require('./webpack.base.conf')\nvar HtmlWebpackPlugin = require('html-webpack-plugin')\nvar FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')\n\n// add hot-reload related code to entry chunks\nObject.keys(baseWebpackConfig.entry).forEach(function (name) {\n  baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name])\n})\n\nmodule.exports = merge(baseWebpackConfig, {\n  module: {\n    rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })\n  },\n  // cheap-module-eval-source-map is faster for development\n  devtool: 'source-map',\n  plugins: [\n    new webpack.DefinePlugin({\n      NODE_ENV: config.dev.env.NODE_ENV\n    }),\n    // https://github.com/glenjamin/webpack-hot-middleware#installation--usage\n    new webpack.HotModuleReplacementPlugin(),\n    new webpack.NoEmitOnErrorsPlugin(),\n    // https://github.com/ampedandwired/html-webpack-plugin\n    new HtmlWebpackPlugin({\n      filename: 'index.html',\n      template: 'index.html',\n      inject: true\n    }),\n    new FriendlyErrorsPlugin()\n  ]\n})\n"
  },
  {
    "path": "build/webpack.prod.conf.js",
    "content": "var path = require('path')\nvar utils = require('./utils')\nvar webpack = require('webpack')\nvar config = require('../config')\nvar merge = require('webpack-merge')\nvar baseWebpackConfig = require('./webpack.base.conf')\nvar CopyWebpackPlugin = require('copy-webpack-plugin')\nvar HtmlWebpackPlugin = require('html-webpack-plugin')\nvar ExtractTextPlugin = require('extract-text-webpack-plugin')\nvar OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')\nvar OfflinePlugin = require('offline-plugin');\nvar WebpackPwaManifest = require('webpack-pwa-manifest')\nvar FaviconsWebpackPlugin = require('favicons-webpack-plugin')\n\nfunction resolve (dir) {\n  return path.join(__dirname, '..', dir)\n}\n\nvar env = config.build.env\n\nvar webpackConfig = merge(baseWebpackConfig, {\n  module: {\n    rules: utils.styleLoaders({\n      sourceMap: config.build.productionSourceMap,\n      extract: true\n    })\n  },\n  devtool: config.build.productionSourceMap ? '#source-map' : false,\n  output: {\n    path: config.build.assetsRoot,\n    filename: utils.assetsPath('js/[name].[chunkhash].js'),\n    chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')\n  },\n  plugins: [\n    // http://vuejs.github.io/vue-loader/en/workflow/production.html\n    new webpack.DefinePlugin({\n      NODE_ENV: env.NODE_ENV,\n      GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID,\n      GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID\n    }),\n    new webpack.optimize.UglifyJsPlugin({\n      compress: {\n        warnings: false\n      },\n      sourceMap: true\n    }),\n    // extract css into its own file\n    new ExtractTextPlugin({\n      filename: utils.assetsPath('css/[name].[contenthash].css')\n    }),\n    // Compress extracted CSS. We are using this plugin so that possible\n    // duplicated CSS from different components can be deduped.\n    new OptimizeCSSPlugin({\n      cssProcessorOptions: {\n        safe: true\n      }\n    }),\n    // generate dist index.html with correct asset hash for caching.\n    // you can customize output by editing /index.html\n    // see https://github.com/ampedandwired/html-webpack-plugin\n    new HtmlWebpackPlugin({\n      filename: config.build.index,\n      template: 'index.html',\n      inject: true,\n      minify: {\n        removeComments: true,\n        collapseWhitespace: true,\n        removeAttributeQuotes: true\n        // more options:\n        // https://github.com/kangax/html-minifier#options-quick-reference\n      },\n      // necessary to consistently work with multiple chunks via CommonsChunkPlugin\n      chunksSortMode: 'dependency'\n    }),\n    // split vendor js into its own file\n    new webpack.optimize.CommonsChunkPlugin({\n      name: 'vendor',\n      minChunks: function (module, count) {\n        // any required modules inside node_modules are extracted to vendor\n        return (\n          module.resource &&\n          /\\.js$/.test(module.resource) &&\n          module.resource.indexOf(\n            path.join(__dirname, '../node_modules')\n          ) === 0\n        )\n      }\n    }),\n    // extract webpack runtime and module manifest to its own file in order to\n    // prevent vendor hash from being updated whenever app bundle is updated\n    new webpack.optimize.CommonsChunkPlugin({\n      name: 'manifest',\n      chunks: ['vendor']\n    }),\n    // copy custom static assets\n    new CopyWebpackPlugin([\n      {\n        from: path.resolve(__dirname, '../static'),\n        to: config.build.assetsSubDirectory,\n        ignore: ['.*']\n      }\n    ]),\n    new FaviconsWebpackPlugin({\n      logo: resolve('src/assets/favicon.png'),\n      title: 'StackEdit',\n    }),\n    new WebpackPwaManifest({\n      name: 'StackEdit',\n      description: 'Full-featured, open-source Markdown editor',\n      display: 'standalone',\n      orientation: 'any',\n      start_url: 'app',\n      background_color: '#ffffff',\n      crossorigin: 'use-credentials',\n      icons: [{\n        src: resolve('src/assets/favicon.png'),\n        sizes: [96, 128, 192, 256, 384, 512]\n      }]\n    }),\n    new OfflinePlugin({\n      ServiceWorker: {\n        events: true\n      },\n      AppCache: true,\n      excludes: ['**/.*', '**/*.map', '**/index.html', '**/static/oauth2/callback.html', '**/icons-*/*.png', '**/static/fonts/KaTeX_*'],\n      externals: ['/', '/app', '/oauth2/callback']\n    }),\n  ]\n})\n\nif (config.build.productionGzip) {\n  var CompressionWebpackPlugin = require('compression-webpack-plugin')\n\n  webpackConfig.plugins.push(\n    new CompressionWebpackPlugin({\n      asset: '[path].gz[query]',\n      algorithm: 'gzip',\n      test: new RegExp(\n        '\\\\.(' +\n        config.build.productionGzipExtensions.join('|') +\n        ')$'\n      ),\n      threshold: 10240,\n      minRatio: 0.8\n    })\n  )\n}\n\nif (config.build.bundleAnalyzerReport) {\n  var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin\n  webpackConfig.plugins.push(new BundleAnalyzerPlugin())\n}\n\nmodule.exports = webpackConfig\n"
  },
  {
    "path": "build/webpack.style.conf.js",
    "content": "var path = require('path')\nvar utils = require('./utils')\nvar webpack = require('webpack')\nvar utils = require('./utils')\nvar config = require('../config')\nvar vueLoaderConfig = require('./vue-loader.conf')\nvar StylelintPlugin = require('stylelint-webpack-plugin')\nvar ExtractTextPlugin = require('extract-text-webpack-plugin')\nvar OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')\n\nfunction resolve (dir) {\n  return path.join(__dirname, '..', dir)\n}\n\nmodule.exports = {\n  entry: {\n    style: './src/styles/'\n  },\n  module: {\n    rules: [{\n      test: /\\.(ttf|eot|otf|woff2?)(\\?.*)?$/,\n      loader: 'file-loader',\n      options: {\n        name: utils.assetsPath('fonts/[name].[hash:7].[ext]')\n      }\n    }]\n    .concat(utils.styleLoaders({\n      sourceMap: config.build.productionSourceMap,\n      extract: true\n    })),\n  },\n  output: {\n    path: config.build.assetsRoot,\n    filename: '[name].js',\n    publicPath: config.build.assetsPublicPath\n  },\n  plugins: [\n    new webpack.optimize.UglifyJsPlugin({\n      compress: {\n        warnings: false\n      },\n      sourceMap: true\n    }),\n    // extract css into its own file\n    new ExtractTextPlugin({\n      filename: '[name].css',\n    }),\n    // Compress extracted CSS. We are using this plugin so that possible\n    // duplicated CSS from different components can be deduped.\n    new OptimizeCSSPlugin({\n      cssProcessorOptions: {\n        safe: true\n      }\n    }),\n  ]\n}\n"
  },
  {
    "path": "chart/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "chart/Chart.yaml",
    "content": "apiVersion: v1\nappVersion: vSTACKEDIT_VERSION\ndescription: In-browser Markdown editor\nname: stackedit\nversion: STACKEDIT_VERSION\n"
  },
  {
    "path": "chart/templates/NOTES.txt",
    "content": "1. Get the application URL by running these commands:\n{{- if .Values.ingress.enabled }}\n{{- range $host := .Values.ingress.hosts }}\n  {{- range .paths }}\n  http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}\n  {{- end }}\n{{- end }}\n{{- else if contains \"NodePort\" .Values.service.type }}\n  export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath=\"{.spec.ports[0].nodePort}\" services {{ include \"stackedit.fullname\" . }})\n  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath=\"{.items[0].status.addresses[0].address}\")\n  echo http://$NODE_IP:$NODE_PORT\n{{- else if contains \"LoadBalancer\" .Values.service.type }}\n     NOTE: It may take a few minutes for the LoadBalancer IP to be available.\n           You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include \"stackedit.fullname\" . }}'\n  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include \"stackedit.fullname\" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')\n  echo http://$SERVICE_IP:{{ .Values.service.port }}\n{{- else if contains \"ClusterIP\" .Values.service.type }}\n  export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l \"app.kubernetes.io/name={{ include \"stackedit.name\" . }},app.kubernetes.io/instance={{ .Release.Name }}\" -o jsonpath=\"{.items[0].metadata.name}\")\n  echo \"Visit http://127.0.0.1:8080 to use your application\"\n  kubectl port-forward $POD_NAME 8080:80\n{{- end }}\n"
  },
  {
    "path": "chart/templates/_helpers.tpl",
    "content": "{{/* vim: set filetype=mustache: */}}\n{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"stackedit.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"stackedit.fullname\" -}}\n{{- if .Values.fullnameOverride -}}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" -}}\n{{- else -}}\n{{- $name := default .Chart.Name .Values.nameOverride -}}\n{{- if contains $name .Release.Name -}}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" -}}\n{{- else -}}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n{{- end -}}\n{{- end -}}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"stackedit.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"stackedit.labels\" -}}\napp.kubernetes.io/name: {{ include \"stackedit.name\" . }}\nhelm.sh/chart: {{ include \"stackedit.chart\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end -}}\n"
  },
  {
    "path": "chart/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"stackedit.fullname\" . }}\n  labels:\n{{ include \"stackedit.labels\" . | indent 4 }}\nspec:\n  replicas: {{ .Values.replicaCount }}\n  selector:\n    matchLabels:\n      app.kubernetes.io/name: {{ include \"stackedit.name\" . }}\n      app.kubernetes.io/instance: {{ .Release.Name }}\n  template:\n    metadata:\n      labels:\n        app.kubernetes.io/name: {{ include \"stackedit.name\" . }}\n        app.kubernetes.io/instance: {{ .Release.Name }}\n    spec:\n    {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n      containers:\n        - name: {{ .Chart.Name }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          volumeMounts:\n            - mountPath: /run\n              name: run-volume\n            - mountPath: /tmp\n              name: tmp-volume\n          env:\n            - name: PORT\n              value: \"80\"\n            - name: PAYPAL_RECEIVER_EMAIL\n              value: {{ .Values.paypalReceiverEmail }}\n            - name: AWS_ACCESS_KEY_ID\n              value: {{ .Values.awsAccessKeyId }}\n            - name: AWS_SECRET_ACCESS_KEY\n              value: {{ .Values.awsSecretAccessKey }}\n            - name: DROPBOX_APP_KEY\n              value: {{ .Values.dropboxAppKey }}\n            - name: DROPBOX_APP_KEY_FULL\n              value: {{ .Values.dropboxAppKeyFull }}\n            - name: GOOGLE_CLIENT_ID\n              value: {{ .Values.googleClientId }}\n            - name: GOOGLE_API_KEY\n              value: {{ .Values.googleApiKey }}\n            - name: GITHUB_CLIENT_ID\n              value: {{ .Values.githubClientId }}\n            - name: GITHUB_CLIENT_SECRET\n              value: {{ .Values.githubClientSecret }}\n            - name: WORDPRESS_CLIENT_ID\n              value: {{ .Values.wordpressClientId }}\n            - name: WORDPRESS_SECRET\n              value: {{ .Values.wordpressSecret }}\n          ports:\n            - name: http\n              containerPort: 80\n              protocol: TCP\n          livenessProbe:\n            httpGet:\n              path: /\n              port: http\n          readinessProbe:\n            httpGet:\n              path: /\n              port: http\n          resources:\n            {{- toYaml .Values.resources | nindent 12 }}\n      volumes:\n      - name: run-volume\n        emptyDir: {}\n      - name: tmp-volume\n        emptyDir: {}\n      {{- with .Values.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n    {{- with .Values.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n    {{- with .Values.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n"
  },
  {
    "path": "chart/templates/ingress.yaml",
    "content": "{{- if .Values.ingress.enabled -}}\n{{- $fullName := include \"stackedit.fullname\" . -}}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: {{ $fullName }}\n  labels:\n{{ include \"stackedit.labels\" . | indent 4 }}\n  {{- with .Values.ingress.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n{{- if .Values.ingress.tls }}\n  tls:\n  {{- range .Values.ingress.tls }}\n    - hosts:\n      {{- range .hosts }}\n        - {{ . | quote }}\n      {{- end }}\n      secretName: {{ .secretName }}\n  {{- end }}\n{{- end }}\n  rules:\n  {{- range .Values.ingress.hosts }}\n    - host: {{ .host | quote }}\n      http:\n        paths:\n        {{- range .paths }}\n          - path: {{ . }}\n            pathType: Prefix\n            backend:\n              service:\n                name: {{ $fullName }}\n                port:\n                  name: http\n        {{- end }}\n  {{- end }}\n{{- end }}\n"
  },
  {
    "path": "chart/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"stackedit.fullname\" . }}\n  labels:\n{{ include \"stackedit.labels\" . | indent 4 }}\nspec:\n  type: {{ .Values.service.type }}\n  ports:\n    - port: {{ .Values.service.port }}\n      targetPort: http\n      protocol: TCP\n      name: http\n  selector:\n    app.kubernetes.io/name: {{ include \"stackedit.name\" . }}\n    app.kubernetes.io/instance: {{ .Release.Name }}\n"
  },
  {
    "path": "chart/templates/tests/test-connection.yaml",
    "content": "apiVersion: v1\nkind: Pod\nmetadata:\n  name: \"{{ include \"stackedit.fullname\" . }}-test-connection\"\n  labels:\n{{ include \"stackedit.labels\" . | indent 4 }}\n  annotations:\n    \"helm.sh/hook\": test-success\nspec:\n  containers:\n    - name: wget\n      image: busybox\n      command: ['wget']\n      args:  ['{{ include \"stackedit.fullname\" . }}:{{ .Values.service.port }}']\n  restartPolicy: Never\n"
  },
  {
    "path": "chart/values.yaml",
    "content": "# Default values for stackedit.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\ndropboxAppKey: \"\"\ndropboxAppKeyFull: \"\"\ngoogleClientId: \"\"\ngoogleApiKey: \"\"\ngithubClientId: \"\"\ngithubClientSecret: \"\"\nwordpressClientId: \"\"\nwordpressSecret: \"\"\npaypalReceiverEmail: \"\"\nawsAccessKeyId: \"\"\nawsSecretAccessKey: \"\"\n\nreplicaCount: 1\n\nimage:\n  repository: benweet/stackedit\n  tag: vSTACKEDIT_VERSION\n  pullPolicy: IfNotPresent\n\nimagePullSecrets: []\nnameOverride: \"\"\nfullnameOverride: \"\"\n\nservice:\n  type: ClusterIP\n  port: 80\n\ningress:\n  enabled: false\n  annotations:\n   # kubernetes.io/ingress.class: nginx\n   # certmanager.k8s.io/issuer: letsencrypt-prod\n   # certmanager.k8s.io/acme-challenge-type: http01\n  hosts: []\n   # - host: stackedit.example.com\n   #   paths:\n   #     - /\n\n  tls: []\n   # - secretName: stackedit-tls\n   #   hosts:\n   #     - stackedit.example.com\n\nresources: {}\n  # We usually recommend not to specify default resources and to leave this as a conscious\n  # choice for the user. This also increases chances charts run on environments with little\n  # resources, such as Minikube. If you do want to specify resources, uncomment the following\n  # lines, adjust them as necessary, and remove the curly braces after 'resources:'.\n  # limits:\n  #   cpu: 100m\n  #   memory: 128Mi\n  # requests:\n  #   cpu: 100m\n  #   memory: 128Mi\n\nnodeSelector: {}\n\ntolerations: []\n\naffinity: {}\n"
  },
  {
    "path": "chrome-app/manifest.json",
    "content": "{\n  \"name\": \"StackEdit\",\n  \"description\": \"In-browser Markdown editor\",\n  \"version\": \"1.0.13\",\n  \"manifest_version\": 2,\n  \"container\" : \"GOOGLE_DRIVE\",\n  \"api_console_project_id\" : \"241271498917\",\n  \"icons\": {\n    \"16\": \"icon-16.png\",\n    \"32\": \"icon-32.png\",\n    \"64\": \"icon-64.png\",\n    \"128\": \"icon-128.png\",\n    \"256\": \"icon-256.png\",\n    \"512\": \"icon-512.png\"\n  },\n  \"app\": {\n    \"urls\": [\n      \"https://stackedit.io/\"\n    ],\n    \"launch\": {\n      \"web_url\": \"https://stackedit.io/app\"\n    }\n  },\n  \"offline_enabled\": true,\n  \"permissions\": [\n    \"unlimitedStorage\"\n  ]\n}\n"
  },
  {
    "path": "config/dev.env.js",
    "content": "var merge = require('webpack-merge')\nvar prodEnv = require('./prod.env')\n\nmodule.exports = merge(prodEnv, {\n  NODE_ENV: '\"development\"'\n})\n"
  },
  {
    "path": "config/index.js",
    "content": "// see http://vuejs-templates.github.io/webpack for documentation.\nvar path = require('path')\n\nmodule.exports = {\n  build: {\n    env: require('./prod.env'),\n    index: path.resolve(__dirname, '../dist/index.html'),\n    assetsRoot: path.resolve(__dirname, '../dist'),\n    assetsSubDirectory: 'static',\n    assetsPublicPath: '/',\n    productionSourceMap: true,\n    // Gzip off by default as many popular static hosts such as\n    // Surge or Netlify already gzip all static assets for you.\n    // Before setting to `true`, make sure to:\n    // npm install --save-dev compression-webpack-plugin\n    productionGzip: false,\n    productionGzipExtensions: ['js', 'css'],\n    // Run the build command with an extra argument to\n    // View the bundle analyzer report after build finishes:\n    // `npm run build --report`\n    // Set to `true` or `false` to always turn it on or off\n    bundleAnalyzerReport: process.env.npm_config_report\n  },\n  dev: {\n    env: require('./dev.env'),\n    port: 8080,\n    autoOpenBrowser: false,\n    assetsSubDirectory: 'static',\n    assetsPublicPath: '/',\n    proxyTable: {},\n    // CSS Sourcemaps off by default because relative paths are \"buggy\"\n    // with this option, according to the CSS-Loader README\n    // (https://github.com/webpack/css-loader#sourcemaps)\n    // In our experience, they generally work as expected,\n    // just be aware of this issue when enabling this option.\n    // cssSourceMap: false\n    cssSourceMap: true\n  }\n}\n"
  },
  {
    "path": "config/prod.env.js",
    "content": "module.exports = {\n  NODE_ENV: '\"production\"'\n}\n"
  },
  {
    "path": "gulpfile.js",
    "content": "const path = require('path');\nconst gulp = require('gulp');\nconst concat = require('gulp-concat');\n\nconst prismScripts = [\n  'prismjs/components/prism-core',\n  'prismjs/components/prism-markup',\n  'prismjs/components/prism-clike',\n  'prismjs/components/prism-c',\n  'prismjs/components/prism-javascript',\n  'prismjs/components/prism-css',\n  'prismjs/components/prism-ruby',\n  'prismjs/components/prism-cpp',\n].map(require.resolve);\nprismScripts.push(\n  path.join(path.dirname(require.resolve('prismjs/components/prism-core')), 'prism-!(*.min).js'));\n\ngulp.task('build-prism', () => gulp.src(prismScripts)\n  .pipe(concat('prism.js'))\n  .pipe(gulp.dest(path.dirname(require.resolve('prismjs')))));\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>StackEdit</title>\n    <link rel=\"canonical\" href=\"https://stackedit.io/app\">\n    <meta name=\"description\" content=\"Free, open-source, full-featured Markdown editor.\">\n    <meta name=\"viewport\" content=\"user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1\">\n    <meta name=\"mobile-web-app-capable\" content=\"yes\">\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black\">\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <!-- built files will be auto injected -->\n  </body>\n</html>\n"
  },
  {
    "path": "index.js",
    "content": "const env = require('./config/prod.env');\n\nObject.keys(env).forEach((key) => {\n  if (!process.env[key]) {\n    process.env[key] = JSON.parse(env[key]);\n  }\n});\n\nconst http = require('http');\nconst express = require('express');\n\nconst app = express();\n\nrequire('./server')(app);\n\nconst port = parseInt(process.env.PORT || 8080, 10);\nconst httpServer = http.createServer(app);\nhttpServer.listen(port, null, () => {\n  console.log(`HTTP server started: http://localhost:${port}`);\n});\n\n// Handle graceful shutdown\nprocess.on('SIGTERM', () => {\n  httpServer.close(() => {\n    process.exit(0);\n  });\n});\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"stackedit\",\n  \"version\": \"5.15.4\",\n  \"description\": \"Free, open-source, full-featured Markdown editor\",\n  \"author\": \"Benoit Schweblin\",\n  \"license\": \"Apache-2.0\",\n  \"bugs\": {\n    \"url\": \"https://github.com/benweet/stackedit/issues\"\n  },\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"postinstall\": \"gulp build-prism\",\n    \"start\": \"node build/dev-server.js\",\n    \"build\": \"node build/build.js && npm run build-style\",\n    \"build-style\": \"webpack --config build/webpack.style.conf.js\",\n    \"lint\": \"eslint --ext .js,.vue src server\",\n    \"unit\": \"jest --config test/unit/jest.conf.js --runInBand\",\n    \"unit-with-coverage\": \"jest --config test/unit/jest.conf.js --runInBand --coverage\",\n    \"test\": \"npm run lint && npm run unit\",\n    \"preversion\": \"npm run test\",\n    \"postversion\": \"git push origin master --tags && npm publish\",\n    \"patch\": \"npm version patch -m \\\"Tag v%s\\\"\",\n    \"minor\": \"npm version minor -m \\\"Tag v%s\\\"\",\n    \"major\": \"npm version major -m \\\"Tag v%s\\\"\",\n    \"chart\": \"mkdir -p dist && rm -rf dist/stackedit && cp -r chart dist/stackedit && sed -i.bak -e s/STACKEDIT_VERSION/$npm_package_version/g dist/stackedit/*.yaml && rm dist/stackedit/*.yaml.bak\"\n  },\n  \"dependencies\": {\n    \"@vue/test-utils\": \"^1.0.0-beta.16\",\n    \"abcjs\": \"^5.2.0\",\n    \"aws-sdk\": \"^2.1380.0\",\n    \"babel-runtime\": \"^6.26.0\",\n    \"bezier-easing\": \"^1.1.0\",\n    \"body-parser\": \"^1.18.2\",\n    \"clipboard\": \"^1.7.1\",\n    \"compression\": \"^1.7.0\",\n    \"diff-match-patch\": \"^1.0.0\",\n    \"file-saver\": \"^1.3.8\",\n    \"google-id-token-verifier\": \"^0.2.3\",\n    \"handlebars\": \"^4.0.10\",\n    \"indexeddbshim\": \"^3.6.2\",\n    \"js-yaml\": \"^3.11.0\",\n    \"katex\": \"^0.13.0\",\n    \"markdown-it\": \"^8.4.1\",\n    \"markdown-it-abbr\": \"^1.0.4\",\n    \"markdown-it-deflist\": \"^2.0.2\",\n    \"markdown-it-emoji\": \"^1.3.0\",\n    \"markdown-it-footnote\": \"^3.0.1\",\n    \"markdown-it-imsize\": \"^2.0.1\",\n    \"markdown-it-mark\": \"^2.0.0\",\n    \"markdown-it-pandoc-renderer\": \"1.2.0\",\n    \"markdown-it-sub\": \"^1.0.0\",\n    \"markdown-it-sup\": \"^1.0.0\",\n    \"mermaid\": \"^8.9.2\",\n    \"mousetrap\": \"^1.6.1\",\n    \"normalize-scss\": \"^7.0.1\",\n    \"prismjs\": \"^1.6.0\",\n    \"request\": \"^2.85.0\",\n    \"serve-static\": \"^1.13.2\",\n    \"tmp\": \"^0.0.33\",\n    \"turndown\": \"^4.0.2\",\n    \"vue\": \"^2.5.16\",\n    \"vuex\": \"^3.0.1\"\n  },\n  \"devDependencies\": {\n    \"autoprefixer\": \"^6.7.2\",\n    \"babel-core\": \"^6.26.3\",\n    \"babel-eslint\": \"^8.2.3\",\n    \"babel-jest\": \"^21.0.2\",\n    \"babel-loader\": \"^7.1.4\",\n    \"babel-plugin-dynamic-import-node\": \"^1.2.0\",\n    \"babel-plugin-transform-es2015-modules-commonjs\": \"^6.26.2\",\n    \"babel-plugin-transform-runtime\": \"^6.23.0\",\n    \"babel-polyfill\": \"^6.23.0\",\n    \"babel-preset-env\": \"^1.7.0\",\n    \"babel-preset-stage-2\": \"^6.22.0\",\n    \"babel-register\": \"^6.22.0\",\n    \"chalk\": \"^1.1.3\",\n    \"connect-history-api-fallback\": \"^1.3.0\",\n    \"copy-webpack-plugin\": \"^4.5.1\",\n    \"css-loader\": \"^0.28.11\",\n    \"eslint\": \"^4.19.1\",\n    \"eslint-config-airbnb-base\": \"^12.1.0\",\n    \"eslint-friendly-formatter\": \"^4.0.1\",\n    \"eslint-import-resolver-webpack\": \"^0.9.0\",\n    \"eslint-loader\": \"^2.0.0\",\n    \"eslint-plugin-html\": \"^4.0.3\",\n    \"eslint-plugin-import\": \"^2.11.0\",\n    \"eventsource-polyfill\": \"^0.9.6\",\n    \"express\": \"^4.16.3\",\n    \"extract-text-webpack-plugin\": \"^2.0.0\",\n    \"favicons-webpack-plugin\": \"^0.0.9\",\n    \"file-loader\": \"^1.1.11\",\n    \"friendly-errors-webpack-plugin\": \"^1.7.0\",\n    \"gulp\": \"^4.0.2\",\n    \"gulp-concat\": \"^2.6.1\",\n    \"html-webpack-plugin\": \"^3.2.0\",\n    \"http-proxy-middleware\": \"^0.18.0\",\n    \"identity-obj-proxy\": \"^3.0.0\",\n    \"ignore-loader\": \"^0.1.2\",\n    \"jest\": \"^23.0.0\",\n    \"jest-raw-loader\": \"^1.0.1\",\n    \"jest-serializer-vue\": \"^0.3.0\",\n    \"node-sass\": \"^4.0.0\",\n    \"npm-bump\": \"^0.0.23\",\n    \"offline-plugin\": \"^5.0.3\",\n    \"opn\": \"^4.0.2\",\n    \"optimize-css-assets-webpack-plugin\": \"^1.3.2\",\n    \"ora\": \"^1.2.0\",\n    \"raw-loader\": \"^0.5.1\",\n    \"replace-in-file\": \"^4.1.0\",\n    \"rimraf\": \"^2.6.0\",\n    \"sass-loader\": \"^7.0.1\",\n    \"semver\": \"^5.5.0\",\n    \"shelljs\": \"^0.8.1\",\n    \"string-replace-loader\": \"^2.1.1\",\n    \"stylelint\": \"^9.2.0\",\n    \"stylelint-config-standard\": \"^16.0.0\",\n    \"stylelint-processor-html\": \"^1.0.0\",\n    \"stylelint-webpack-plugin\": \"^0.10.4\",\n    \"url-loader\": \"^1.0.1\",\n    \"vue-jest\": \"^1.0.2\",\n    \"vue-loader\": \"^15.0.9\",\n    \"vue-style-loader\": \"^4.1.0\",\n    \"vue-template-compiler\": \"^2.5.16\",\n    \"webpack\": \"^2.6.1\",\n    \"webpack-bundle-analyzer\": \"^3.3.2\",\n    \"webpack-dev-middleware\": \"^1.10.0\",\n    \"webpack-hot-middleware\": \"^2.18.0\",\n    \"webpack-merge\": \"^4.1.2\",\n    \"webpack-pwa-manifest\": \"^3.7.1\",\n    \"worker-loader\": \"^1.1.1\"\n  },\n  \"engines\": {\n    \"node\": \">= 8.0.0\",\n    \"npm\": \">= 5.0.0\"\n  },\n  \"browserslist\": [\n    \"> 1%\",\n    \"last 2 versions\",\n    \"not ie <= 10\"\n  ]\n}\n"
  },
  {
    "path": "server/conf.js",
    "content": "const pandocPath = process.env.PANDOC_PATH || 'pandoc';\nconst wkhtmltopdfPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf';\nconst userBucketName = process.env.USER_BUCKET_NAME || 'stackedit-users';\nconst paypalUri = process.env.PAYPAL_URI || 'https://www.paypal.com/cgi-bin/webscr';\nconst paypalReceiverEmail = process.env.PAYPAL_RECEIVER_EMAIL;\n\nconst dropboxAppKey = process.env.DROPBOX_APP_KEY;\nconst dropboxAppKeyFull = process.env.DROPBOX_APP_KEY_FULL;\nconst githubClientId = process.env.GITHUB_CLIENT_ID;\nconst githubClientSecret = process.env.GITHUB_CLIENT_SECRET;\nconst googleClientId = process.env.GOOGLE_CLIENT_ID;\nconst googleApiKey = process.env.GOOGLE_API_KEY;\nconst wordpressClientId = process.env.WORDPRESS_CLIENT_ID;\n\nexports.values = {\n  pandocPath,\n  wkhtmltopdfPath,\n  userBucketName,\n  paypalUri,\n  paypalReceiverEmail,\n  dropboxAppKey,\n  dropboxAppKeyFull,\n  githubClientId,\n  githubClientSecret,\n  googleClientId,\n  googleApiKey,\n  wordpressClientId,\n};\n\nexports.publicValues = {\n  dropboxAppKey,\n  dropboxAppKeyFull,\n  githubClientId,\n  googleClientId,\n  googleApiKey,\n  wordpressClientId,\n  allowSponsorship: !!paypalReceiverEmail,\n};\n"
  },
  {
    "path": "server/github.js",
    "content": "const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies\nconst request = require('request');\nconst conf = require('./conf');\n\nfunction githubToken(clientId, code) {\n  return new Promise((resolve, reject) => {\n    request({\n      method: 'POST',\n      url: 'https://github.com/login/oauth/access_token',\n      qs: {\n        client_id: clientId,\n        client_secret: conf.values.githubClientSecret,\n        code,\n      },\n    }, (err, res, body) => {\n      if (err) {\n        reject(err);\n      }\n      const token = qs.parse(body).access_token;\n      if (token) {\n        resolve(token);\n      } else {\n        reject(res.statusCode);\n      }\n    });\n  });\n}\n\nexports.githubToken = (req, res) => {\n  githubToken(req.query.clientId, req.query.code)\n    .then(\n      token => res.send(token),\n      err => res\n        .status(400)\n        .send(err ? err.message || err.toString() : 'bad_code'),\n    );\n};\n"
  },
  {
    "path": "server/index.js",
    "content": "const compression = require('compression');\nconst serveStatic = require('serve-static');\nconst bodyParser = require('body-parser');\nconst path = require('path');\nconst user = require('./user');\nconst github = require('./github');\nconst pdf = require('./pdf');\nconst pandoc = require('./pandoc');\nconst conf = require('./conf');\n\nconst resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve);\n\nmodule.exports = (app) => {\n  if (process.env.NODE_ENV === 'production') {\n    // Enable CORS for fonts\n    app.all('*', (req, res, next) => {\n      if (/\\.(eot|ttf|woff2?|svg)$/.test(req.url)) {\n        res.header('Access-Control-Allow-Origin', '*');\n      }\n      next();\n    });\n\n    // Use gzip compression\n    app.use(compression());\n  }\n\n  app.get('/oauth2/githubToken', github.githubToken);\n  app.get('/conf', (req, res) => res.send(conf.publicValues));\n  app.get('/userInfo', user.userInfo);\n  app.post('/pdfExport', pdf.generate);\n  app.post('/pandocExport', pandoc.generate);\n  app.post('/paypalIpn', bodyParser.urlencoded({\n    extended: false,\n  }), user.paypalIpn);\n\n  // Serve landing.html\n  app.get('/', (req, res) => res.sendFile(resolvePath('static/landing/index.html')));\n  // Serve sitemap.xml\n  app.get('/sitemap.xml', (req, res) => res.sendFile(resolvePath('static/sitemap.xml')));\n  // Serve callback.html\n  app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html')));\n  // Google Drive action receiver\n  app.get('/googleDriveAction', (req, res) =>\n    res.redirect(`./app#providerId=googleDrive&state=${encodeURIComponent(req.query.state)}`));\n\n  // Serve static resources\n  if (process.env.NODE_ENV === 'production') {\n    // Serve index.html in /app\n    app.get('/app', (req, res) => res.sendFile(resolvePath('dist/index.html')));\n\n    // Serve style.css with 1 day max-age\n    app.get('/style.css', (req, res) => res.sendFile(resolvePath('dist/style.css'), {\n      maxAge: '1d',\n    }));\n\n    // Serve the static folder with 1 year max-age\n    app.use('/static', serveStatic(resolvePath('dist/static'), {\n      maxAge: '1y',\n    }));\n\n    app.use(serveStatic(resolvePath('dist')));\n  }\n};\n"
  },
  {
    "path": "server/pandoc.js",
    "content": "/* global window */\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst tmp = require('tmp');\nconst user = require('./user');\nconst conf = require('./conf');\n\nconst outputFormats = {\n  asciidoc: 'text/plain',\n  context: 'application/x-latex',\n  epub: 'application/epub+zip',\n  epub3: 'application/epub+zip',\n  latex: 'application/x-latex',\n  odt: 'application/vnd.oasis.opendocument.text',\n  pdf: 'application/pdf',\n  rst: 'text/plain',\n  rtf: 'application/rtf',\n  textile: 'text/plain',\n  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n};\n\nconst highlightStyles = [\n  'pygments',\n  'kate',\n  'monochrome',\n  'espresso',\n  'zenburn',\n  'haddock',\n  'tango',\n];\n\nconst readJson = (str) => {\n  try {\n    return JSON.parse(str);\n  } catch (e) {\n    return {};\n  }\n};\n\nexports.generate = (req, res) => {\n  let pandocError = '';\n  const outputFormat = Object.prototype.hasOwnProperty.call(outputFormats, req.query.format)\n    ? req.query.format\n    : 'pdf';\n  user.checkSponsor(req.query.idToken)\n    .then((isSponsor) => {\n      if (!isSponsor) {\n        throw new Error('unauthorized');\n      }\n\n      return new Promise((resolve, reject) => {\n        tmp.file({\n          postfix: `.${outputFormat}`,\n        }, (err, filePath, fd, cleanupCallback) => {\n          if (err) {\n            reject(err);\n          } else {\n            resolve({\n              filePath,\n              cleanupCallback,\n            });\n          }\n        });\n      });\n    })\n    .then(({ filePath, cleanupCallback }) => new Promise((resolve, reject) => {\n      const options = readJson(req.query.options);\n      const metadata = readJson(req.query.metadata);\n      const params = [];\n\n      params.push('--pdf-engine=xelatex');\n      params.push('--webtex=http://chart.apis.google.com/chart?cht=tx&chf=bg,s,FFFFFF00&chco=000000&chl=');\n      if (options.toc) {\n        params.push('--toc');\n      }\n      options.tocDepth = parseInt(options.tocDepth, 10);\n      if (!Number.isNaN(options.tocDepth)) {\n        params.push('--toc-depth', options.tocDepth);\n      }\n      options.highlightStyle = highlightStyles.includes(options.highlightStyle) ? options.highlightStyle : 'kate';\n      params.push('--highlight-style', options.highlightStyle);\n      Object.keys(metadata).forEach((key) => {\n        params.push('-M', `${key}=${metadata[key]}`);\n      });\n\n      let finished = false;\n\n      function onError(error) {\n        finished = true;\n        cleanupCallback();\n        reject(error);\n      }\n\n      const format = outputFormat === 'pdf' ? 'latex' : outputFormat;\n      params.push('-f', 'json', '-t', format, '-o', filePath);\n      const pandoc = spawn(conf.values.pandocPath, params, {\n        stdio: [\n          'pipe',\n          'ignore',\n          'pipe',\n        ],\n      });\n      let timeoutId = setTimeout(() => {\n        timeoutId = null;\n        pandoc.kill();\n      }, 50000);\n      pandoc.on('error', onError);\n      pandoc.stdin.on('error', onError);\n      pandoc.stderr.on('data', (data) => {\n        pandocError += `${data}`;\n      });\n      pandoc.on('close', (code) => {\n        if (!finished) {\n          clearTimeout(timeoutId);\n          if (!timeoutId) {\n            res.statusCode = 408;\n            cleanupCallback();\n            reject(new Error('timeout'));\n          } else if (code) {\n            cleanupCallback();\n            reject();\n          } else {\n            res.set('Content-Type', outputFormats[outputFormat]);\n            const readStream = fs.createReadStream(filePath);\n            readStream.on('open', () => readStream.pipe(res));\n            readStream.on('close', () => cleanupCallback());\n            readStream.on('error', () => {\n              cleanupCallback();\n              reject();\n            });\n          }\n        }\n      });\n      req.pipe(pandoc.stdin);\n    }))\n    .catch((err) => {\n      const message = err && err.message;\n      if (message === 'unauthorized') {\n        res.statusCode = 401;\n        res.end('Unauthorized.');\n      } else if (message === 'timeout') {\n        res.statusCode = 408;\n        res.end('Request timeout.');\n      } else {\n        res.statusCode = 400;\n        res.end(pandocError || 'Unknown error.');\n      }\n    });\n};\n"
  },
  {
    "path": "server/pdf.js",
    "content": "/* global window,MathJax */\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst tmp = require('tmp');\nconst user = require('./user');\nconst conf = require('./conf');\n\n/* eslint-disable no-var, prefer-arrow-callback, func-names */\nfunction waitForJavaScript() {\n  if (window.MathJax) {\n    // Amazon EC2: fix TeX font detection\n    MathJax.Hub.Register.StartupHook('HTML-CSS Jax Startup', function () {\n      var htmlCss = MathJax.OutputJax['HTML-CSS'];\n      htmlCss.Font.checkWebFont = function (check, font, callback) {\n        if (check.time(callback)) {\n          return;\n        }\n        if (check.total === 0) {\n          htmlCss.Font.testFont(font);\n          setTimeout(check, 200);\n        } else {\n          callback(check.STATUS.OK);\n        }\n      };\n    });\n    MathJax.Hub.Queue(function () {\n      window.status = 'done';\n    });\n  } else {\n    setTimeout(function () {\n      window.status = 'done';\n    }, 2000);\n  }\n}\n/* eslint-disable no-var, prefer-arrow-callback, func-names */\n\nconst authorizedPageSizes = [\n  'A3',\n  'A4',\n  'Legal',\n  'Letter',\n];\n\nconst readJson = (str) => {\n  try {\n    return JSON.parse(str);\n  } catch (e) {\n    return {};\n  }\n};\n\nexports.generate = (req, res) => {\n  let wkhtmltopdfError = '';\n  user.checkSponsor(req.query.idToken)\n    .then((isSponsor) => {\n      if (!isSponsor) {\n        throw new Error('unauthorized');\n      }\n      return new Promise((resolve, reject) => {\n        tmp.file((err, filePath, fd, cleanupCallback) => {\n          if (err) {\n            reject(err);\n          } else {\n            resolve({\n              filePath,\n              cleanupCallback,\n            });\n          }\n        });\n      });\n    })\n    .then(({ filePath, cleanupCallback }) => new Promise((resolve, reject) => {\n      let finished = false;\n\n      function onError(err) {\n        finished = true;\n        cleanupCallback();\n        reject(err);\n      }\n      const options = readJson(req.query.options);\n      const params = [];\n\n      // Margins\n      const marginTop = parseInt(`${options.marginTop}`, 10);\n      params.push('-T', Number.isNaN(marginTop) ? 25 : marginTop);\n      const marginRight = parseInt(`${options.marginRight}`, 10);\n      params.push('-R', Number.isNaN(marginRight) ? 25 : marginRight);\n      const marginBottom = parseInt(`${options.marginBottom}`, 10);\n      params.push('-B', Number.isNaN(marginBottom) ? 25 : marginBottom);\n      const marginLeft = parseInt(`${options.marginLeft}`, 10);\n      params.push('-L', Number.isNaN(marginLeft) ? 25 : marginLeft);\n\n      // Header\n      if (options.headerCenter) {\n        params.push('--header-center', `${options.headerCenter}`);\n      }\n      if (options.headerLeft) {\n        params.push('--header-left', `${options.headerLeft}`);\n      }\n      if (options.headerRight) {\n        params.push('--header-right', `${options.headerRight}`);\n      }\n      if (options.headerFontName) {\n        params.push('--header-font-name', `${options.headerFontName}`);\n      }\n      if (options.headerFontSize) {\n        params.push('--header-font-size', `${options.headerFontSize}`);\n      }\n\n      // Footer\n      if (options.footerCenter) {\n        params.push('--footer-center', `${options.footerCenter}`);\n      }\n      if (options.footerLeft) {\n        params.push('--footer-left', `${options.footerLeft}`);\n      }\n      if (options.footerRight) {\n        params.push('--footer-right', `${options.footerRight}`);\n      }\n      if (options.footerFontName) {\n        params.push('--footer-font-name', `${options.footerFontName}`);\n      }\n      if (options.footerFontSize) {\n        params.push('--footer-font-size', `${options.footerFontSize}`);\n      }\n\n      // Page size\n      params.push('--page-size', !authorizedPageSizes.includes(options.pageSize) ? 'A4' : options.pageSize);\n\n      // Use a temp file as wkhtmltopdf can't access /dev/stdout on Amazon EC2 for some reason\n      params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`);\n      params.push('--window-status', 'done');\n      const wkhtmltopdf = spawn(conf.values.wkhtmltopdfPath, params.concat('-', filePath), {\n        stdio: [\n          'pipe',\n          'ignore',\n          'pipe',\n        ],\n      });\n      let timeoutId = setTimeout(function () {\n        timeoutId = null;\n        wkhtmltopdf.kill();\n      }, 50000);\n      wkhtmltopdf.on('error', onError);\n      wkhtmltopdf.stdin.on('error', onError);\n      wkhtmltopdf.stderr.on('data', (data) => {\n        wkhtmltopdfError += `${data}`;\n      });\n      wkhtmltopdf.on('close', (code) => {\n        if (!finished) {\n          clearTimeout(timeoutId);\n          if (!timeoutId) {\n            cleanupCallback();\n            reject(new Error('timeout'));\n          } else if (code) {\n            cleanupCallback();\n            reject();\n          } else {\n            res.set('Content-Type', 'application/pdf');\n            const readStream = fs.createReadStream(filePath);\n            readStream.on('open', () => readStream.pipe(res));\n            readStream.on('close', () => cleanupCallback());\n            readStream.on('error', () => {\n              cleanupCallback();\n              reject();\n            });\n          }\n        }\n      });\n      req.pipe(wkhtmltopdf.stdin);\n    }))\n    .catch((err) => {\n      const message = err && err.message;\n      if (message === 'unauthorized') {\n        res.statusCode = 401;\n        res.end('Unauthorized.');\n      } else if (message === 'timeout') {\n        res.statusCode = 408;\n        res.end('Request timeout.');\n      } else {\n        res.statusCode = 400;\n        res.end(wkhtmltopdfError || 'Unknown error.');\n      }\n    });\n};\n"
  },
  {
    "path": "server/user.js",
    "content": "const request = require('request');\nconst AWS = require('aws-sdk');\nconst verifier = require('google-id-token-verifier');\nconst conf = require('./conf');\n\nconst s3Client = new AWS.S3();\n\nconst cb = (resolve, reject) => (err, res) => {\n  if (err) {\n    reject(err);\n  } else {\n    resolve(res);\n  }\n};\n\nexports.getUser = id => new Promise((resolve, reject) => {\n  s3Client.getObject({\n    Bucket: conf.values.userBucketName,\n    Key: id,\n  }, cb(resolve, reject));\n})\n  .then(\n    res => JSON.parse(res.Body.toString('utf-8')),\n    (err) => {\n      if (err.code !== 'NoSuchKey') {\n        throw err;\n      }\n    },\n  );\n\nexports.putUser = (id, user) => new Promise((resolve, reject) => {\n  s3Client.putObject({\n    Bucket: conf.values.userBucketName,\n    Key: id,\n    Body: JSON.stringify(user),\n  }, cb(resolve, reject));\n});\n\nexports.getUserFromToken = idToken => new Promise((resolve, reject) => verifier\n  .verify(idToken, conf.values.googleClientId, cb(resolve, reject)))\n  .then(tokenInfo => exports.getUser(tokenInfo.sub));\n\nexports.userInfo = (req, res) => exports.getUserFromToken(req.query.idToken)\n  .then(\n    user => res.send(Object.assign({\n      sponsorUntil: 0,\n    }, user)),\n    err => res\n      .status(400)\n      .send(err ? err.message || err.toString() : 'invalid_token'),\n  );\n\nexports.paypalIpn = (req, res, next) => Promise.resolve()\n  .then(() => {\n    const userId = req.body.custom;\n    const paypalEmail = req.body.payer_email;\n    const gross = parseFloat(req.body.mc_gross);\n    let sponsorUntil;\n    if (gross === 5) {\n      sponsorUntil = Date.now() + (3 * 31 * 24 * 60 * 60 * 1000); // 3 months\n    } else if (gross === 15) {\n      sponsorUntil = Date.now() + (366 * 24 * 60 * 60 * 1000); // 1 year\n    } else if (gross === 25) {\n      sponsorUntil = Date.now() + (2 * 366 * 24 * 60 * 60 * 1000); // 2 years\n    } else if (gross === 50) {\n      sponsorUntil = Date.now() + (5 * 366 * 24 * 60 * 60 * 1000); // 5 years\n    }\n    if (\n      req.body.receiver_email !== conf.values.paypalReceiverEmail ||\n      req.body.payment_status !== 'Completed' ||\n      req.body.mc_currency !== 'USD' ||\n      (req.body.txn_type !== 'web_accept' && req.body.txn_type !== 'subscr_payment') ||\n      !userId || !sponsorUntil\n    ) {\n      // Ignoring PayPal IPN\n      return res.end();\n    }\n    // Processing PayPal IPN\n    req.body.cmd = '_notify-validate';\n    return new Promise((resolve, reject) => request.post({\n      uri: conf.values.paypalUri,\n      form: req.body,\n    }, (err, response, body) => {\n      if (err) {\n        reject(err);\n      } else if (body !== 'VERIFIED') {\n        reject(new Error('PayPal IPN unverified'));\n      } else {\n        resolve();\n      }\n    }))\n      .then(() => exports.putUser(userId, {\n        paypalEmail,\n        sponsorUntil,\n      }))\n      .then(() => res.end());\n  })\n  .catch(next);\n\nexports.checkSponsor = (idToken) => {\n  if (!conf.publicValues.allowSponsorship) {\n    return Promise.resolve(true);\n  }\n  if (!idToken) {\n    return Promise.resolve(false);\n  }\n  return exports.getUserFromToken(idToken)\n    .then(userInfo => userInfo && userInfo.sponsorUntil > Date.now(), () => false);\n};\n"
  },
  {
    "path": "src/components/App.vue",
    "content": "<template>\n  <div class=\"app\" :class=\"classes\" @keydown.esc=\"close\">\n    <splash-screen v-if=\"!ready\"></splash-screen>\n    <layout v-else></layout>\n    <modal></modal>\n    <notification></notification>\n    <context-menu></context-menu>\n  </div>\n</template>\n\n<script>\nimport '../styles';\nimport '../styles/markdownHighlighting.scss';\nimport '../styles/app.scss';\nimport Layout from './Layout';\nimport Modal from './Modal';\nimport Notification from './Notification';\nimport ContextMenu from './ContextMenu';\nimport SplashScreen from './SplashScreen';\nimport syncSvc from '../services/syncSvc';\nimport networkSvc from '../services/networkSvc';\nimport tempFileSvc from '../services/tempFileSvc';\nimport store from '../store';\nimport './common/vueGlobals';\n\nconst themeClasses = {\n  light: ['app--light'],\n  dark: ['app--dark'],\n};\n\nexport default {\n  components: {\n    Layout,\n    Modal,\n    Notification,\n    ContextMenu,\n    SplashScreen,\n  },\n  data: () => ({\n    ready: false,\n  }),\n  computed: {\n    classes() {\n      const result = themeClasses[store.getters['data/computedSettings'].colorTheme];\n      return Array.isArray(result) ? result : themeClasses.light;\n    },\n  },\n  methods: {\n    close() {\n      tempFileSvc.close();\n    },\n  },\n  async created() {\n    try {\n      await syncSvc.init();\n      await networkSvc.init();\n      this.ready = true;\n      tempFileSvc.setReady();\n    } catch (err) {\n      if (err && err.message === 'RELOAD') {\n        window.location.reload();\n      } else if (err && err.message !== 'RELOAD') {\n        console.error(err); // eslint-disable-line no-console\n        store.dispatch('notification/error', err);\n      }\n    }\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/ButtonBar.vue",
    "content": "<template>\n  <div class=\"button-bar\">\n    <div class=\"button-bar__inner button-bar__inner--top\">\n      <button class=\"button-bar__button button-bar__button--navigation-bar-toggler button\" :class=\"{ 'button-bar__button--on': layoutSettings.showNavigationBar }\" v-if=\"!light\" @click=\"toggleNavigationBar()\" v-title=\"'Toggle navigation bar'\">\n        <icon-navigation-bar></icon-navigation-bar>\n      </button>\n      <button class=\"button-bar__button button-bar__button--side-preview-toggler button\" :class=\"{ 'button-bar__button--on': layoutSettings.showSidePreview }\" tour-step-anchor=\"editor\" @click=\"toggleSidePreview()\" v-title=\"'Toggle side preview'\">\n        <icon-side-preview></icon-side-preview>\n      </button>\n      <button class=\"button-bar__button button-bar__button--editor-toggler button\" @click=\"toggleEditor(false)\" v-title=\"'Reader mode'\">\n        <icon-eye></icon-eye>\n      </button>\n    </div>\n    <div class=\"button-bar__inner button-bar__inner--bottom\">\n      <button class=\"button-bar__button button-bar__button--focus-mode-toggler button\" :class=\"{ 'button-bar__button--on': layoutSettings.focusMode }\" @click=\"toggleFocusMode()\" v-title=\"'Toggle focus mode'\">\n        <icon-target></icon-target>\n      </button>\n      <button class=\"button-bar__button button-bar__button--scroll-sync-toggler button\" :class=\"{ 'button-bar__button--on': layoutSettings.scrollSync }\" @click=\"toggleScrollSync()\" v-title=\"'Toggle scroll sync'\">\n        <icon-scroll-sync></icon-scroll-sync>\n      </button>\n      <button class=\"button-bar__button button-bar__button--status-bar-toggler button\" :class=\"{ 'button-bar__button--on': layoutSettings.showStatusBar }\" @click=\"toggleStatusBar()\" v-title=\"'Toggle status bar'\">\n        <icon-status-bar></icon-status-bar>\n      </button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters, mapActions } from 'vuex';\n\nexport default {\n  computed: {\n    ...mapState([\n      'light',\n    ]),\n    ...mapGetters('data', [\n      'layoutSettings',\n    ]),\n  },\n  methods: mapActions('data', [\n    'toggleNavigationBar',\n    'toggleEditor',\n    'toggleSidePreview',\n    'toggleStatusBar',\n    'toggleFocusMode',\n    'toggleScrollSync',\n  ]),\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.button-bar {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n}\n\n.button-bar__inner {\n  position: absolute;\n}\n\n.button-bar__inner--bottom {\n  bottom: 0;\n}\n\n.button-bar__button {\n  color: rgba(0, 0, 0, 0.2);\n  display: block;\n  width: 26px;\n  height: 26px;\n  padding: 2px;\n  margin: 3px 0;\n\n  .app--dark & {\n    color: rgba(255, 255, 255, 0.15);\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.2);\n\n    .app--dark & {\n      color: rgba(255, 255, 255, 0.15);\n      background-color: $navbar-hover-background;\n    }\n  }\n}\n\n.button-bar__button--on {\n  color: rgba(0, 0, 0, 0.4);\n\n  .app--dark & {\n    color: rgba(255, 255, 255, 0.4);\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.4);\n\n    .app--dark & {\n      color: rgba(255, 255, 255, 0.4);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/CodeEditor.vue",
    "content": "<template>\n  <pre class=\"code-editor textfield prism\" :disabled=\"disabled\"></pre>\n</template>\n\n<script>\nimport Prism from 'prismjs';\nimport cledit from '../services/editor/cledit';\n\nexport default {\n  props: ['value', 'lang', 'disabled'],\n  mounted() {\n    const preElt = this.$el;\n    let scrollElt = preElt;\n    while (scrollElt && !scrollElt.classList.contains('modal')) {\n      scrollElt = scrollElt.parentNode;\n    }\n    if (scrollElt) {\n      const clEditor = cledit(preElt, scrollElt);\n      clEditor.on('contentChanged', value => this.$emit('changed', value));\n      clEditor.init({\n        content: this.value,\n        sectionHighlighter: section => Prism.highlight(section.text, Prism.languages[this.lang]),\n      });\n      clEditor.toggleEditable(!this.disabled);\n    }\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.code-editor {\n  margin: 0;\n  font-family: $font-family-monospace;\n  font-size: $font-size-monospace;\n  font-variant-ligatures: no-common-ligatures;\n  word-break: break-word;\n  word-wrap: normal;\n  height: auto;\n  caret-color: #000;\n  min-height: 160px;\n  overflow: auto;\n  padding: 0.2em 0.4em;\n\n  * {\n    line-height: $line-height-base;\n    font-size: inherit !important;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/ContextMenu.vue",
    "content": "<template>\n  <div class=\"context-menu\" v-if=\"items.length\" @click=\"close()\" @contextmenu.prevent=\"close()\">\n    <div class=\"context-menu__inner flex flex--column\" :style=\"{ left: coordinates.left + 'px', top: coordinates.top + 'px' }\" @click.stop>\n      <div v-for=\"(item, idx) in items\" :key=\"idx\">\n        <div class=\"context-menu__separator\" v-if=\"item.type === 'separator'\"></div>\n        <div class=\"context-menu__item context-menu__item--disabled\" v-else-if=\"item.disabled\">{{item.name}}</div>\n        <a class=\"context-menu__item\" href=\"javascript:void(0)\" v-else @click=\"close(item)\">{{item.name}}</a>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState } from 'vuex';\nimport store from '../store';\n\nexport default {\n  computed: {\n    ...mapState('contextMenu', [\n      'coordinates',\n      'items',\n      'resolve',\n    ]),\n  },\n  methods: {\n    close(item = null) {\n      this.resolve(item);\n      store.dispatch('contextMenu/close');\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.context-menu {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  font-size: 14px;\n  line-height: 18px;\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n  user-select: none;\n}\n\n$padding: 5px;\n\n.context-menu__inner {\n  position: absolute;\n  background-color: #ebebeb;\n  border-radius: $padding;\n  padding: $padding 0;\n  box-shadow: 0 6px 10px rgba(0, 0, 0, 0.16), 0 3px 10px 1px rgba(0, 0, 0, 0.12);\n}\n\n.context-menu__item {\n  display: block;\n  color: #333;\n  text-decoration: none;\n  padding: 0 25px;\n}\n\na.context-menu__item {\n  &:active,\n  &:focus,\n  &:hover {\n    background-color: #338dfc;\n    color: #fff;\n  }\n}\n\n.context-menu__item--disabled {\n  color: #aaa;\n}\n\n.context-menu__separator {\n  border-top: 2px solid #dcdcdd;\n  margin: $padding 0;\n}\n</style>\n"
  },
  {
    "path": "src/components/Editor.vue",
    "content": "<template>\n  <div class=\"editor\">\n    <pre class=\"editor__inner markdown-highlighting\" :style=\"{padding: styles.editorPadding}\" :class=\"{monospaced: computedSettings.editor.monospacedFontOnly}\"></pre>\n    <div class=\"gutter\" :style=\"{left: styles.editorGutterLeft + 'px'}\">\n      <comment-list v-if=\"styles.editorGutterWidth\"></comment-list>\n      <editor-new-discussion-button v-if=\"!isCurrentTemp\"></editor-new-discussion-button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport CommentList from './gutters/CommentList';\nimport EditorNewDiscussionButton from './gutters/EditorNewDiscussionButton';\nimport store from '../store';\n\nexport default {\n  components: {\n    CommentList,\n    EditorNewDiscussionButton,\n  },\n  computed: {\n    ...mapGetters('file', [\n      'isCurrentTemp',\n    ]),\n    ...mapGetters('layout', [\n      'styles',\n    ]),\n    ...mapGetters('data', [\n      'computedSettings',\n    ]),\n  },\n  mounted() {\n    const editorElt = this.$el.querySelector('.editor__inner');\n    const onDiscussionEvt = cb => (evt) => {\n      let elt = evt.target;\n      while (elt && elt !== editorElt) {\n        if (elt.discussionId) {\n          cb(elt.discussionId);\n          return;\n        }\n        elt = elt.parentNode;\n      }\n    };\n\n    const classToggler = toggle => (discussionId) => {\n      editorElt.getElementsByClassName(`discussion-editor-highlighting--${discussionId}`)\n        .cl_each(elt => elt.classList.toggle('discussion-editor-highlighting--hover', toggle));\n      document.getElementsByClassName(`comment--discussion-${discussionId}`)\n        .cl_each(elt => elt.classList.toggle('comment--hover', toggle));\n    };\n\n    editorElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));\n    editorElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));\n    editorElt.addEventListener('click', onDiscussionEvt((discussionId) => {\n      store.commit('discussion/setCurrentDiscussionId', discussionId);\n    }));\n\n    this.$watch(\n      () => store.state.discussion.currentDiscussionId,\n      (discussionId, oldDiscussionId) => {\n        if (oldDiscussionId) {\n          editorElt.querySelectorAll(`.discussion-editor-highlighting--${oldDiscussionId}`)\n            .cl_each(elt => elt.classList.remove('discussion-editor-highlighting--selected'));\n        }\n        if (discussionId) {\n          editorElt.querySelectorAll(`.discussion-editor-highlighting--${discussionId}`)\n            .cl_each(elt => elt.classList.add('discussion-editor-highlighting--selected'));\n        }\n      },\n    );\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.editor {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  overflow: auto;\n}\n\n.editor__inner {\n  margin: 0;\n  font-family: $font-family-main;\n  font-variant-ligatures: no-common-ligatures;\n  white-space: pre-wrap;\n  word-break: break-word;\n  word-wrap: break-word;\n\n  * {\n    line-height: $line-height-base;\n  }\n\n  .cledit-section {\n    font-family: inherit;\n  }\n\n  .hide {\n    display: none;\n  }\n\n  &.monospaced {\n    font-family: $font-family-monospace !important;\n    font-size: $font-size-monospace !important;\n\n    * {\n      font-size: inherit !important;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/Explorer.vue",
    "content": "<template>\n  <div class=\"explorer flex flex--column\">\n    <div class=\"side-title flex flex--row flex--space-between\">\n      <div class=\"flex flex--row\">\n        <button class=\"side-title__button side-title__button--new-file button\" @click=\"newItem()\" v-title=\"'New file'\">\n          <icon-file-plus></icon-file-plus>\n        </button>\n        <button class=\"side-title__button side-title__button--new-folder button\" @click=\"newItem(true)\" v-title=\"'New folder'\">\n          <icon-folder-plus></icon-folder-plus>\n        </button>\n        <button class=\"side-title__button side-title__button--delete button\" @click=\"deleteItem()\" v-title=\"'Delete'\">\n          <icon-delete></icon-delete>\n        </button>\n        <button class=\"side-title__button side-title__button--rename button\" @click=\"editItem()\" v-title=\"'Rename'\">\n          <icon-pen></icon-pen>\n        </button>\n      </div>\n      <button class=\"side-title__button side-title__button--close button\" @click=\"toggleExplorer(false)\" v-title=\"'Close explorer'\">\n        <icon-close></icon-close>\n      </button>\n    </div>\n    <div class=\"explorer__tree\" :class=\"{'explorer__tree--new-item': !newChildNode.isNil}\" v-if=\"!light\" tabindex=\"0\" @keydown.delete=\"deleteItem()\">\n      <explorer-node :node=\"rootNode\" :depth=\"0\"></explorer-node>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters, mapActions } from 'vuex';\nimport ExplorerNode from './ExplorerNode';\nimport explorerSvc from '../services/explorerSvc';\nimport store from '../store';\n\nexport default {\n  components: {\n    ExplorerNode,\n  },\n  computed: {\n    ...mapState([\n      'light',\n    ]),\n    ...mapState('explorer', [\n      'newChildNode',\n    ]),\n    ...mapGetters('explorer', [\n      'rootNode',\n      'selectedNode',\n    ]),\n  },\n  methods: {\n    ...mapActions('data', [\n      'toggleExplorer',\n    ]),\n    newItem: isFolder => explorerSvc.newItem(isFolder),\n    deleteItem: () => explorerSvc.deleteItem(),\n    editItem() {\n      const node = this.selectedNode;\n      if (!node.isTrash && !node.isTemp) {\n        store.commit('explorer/setEditingId', node.item.id);\n      }\n    },\n  },\n  created() {\n    this.$watch(\n      () => store.getters['file/current'].id,\n      (currentFileId) => {\n        store.commit('explorer/setSelectedId', currentFileId);\n        store.dispatch('explorer/openNode', currentFileId);\n      }, {\n        immediate: true,\n      },\n    );\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.explorer,\n.explorer__tree {\n  height: 100%;\n}\n\n.explorer__tree {\n  overflow: auto;\n\n  /* fake element */\n  & > .explorer-node > .explorer-node__children > .explorer-node:last-child > .explorer-node__item {\n    height: 20px;\n    cursor: auto;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/ExplorerNode.vue",
    "content": "<template>\n  <div class=\"explorer-node\" :class=\"{'explorer-node--selected': isSelected, 'explorer-node--folder': node.isFolder, 'explorer-node--open': isOpen, 'explorer-node--trash': node.isTrash, 'explorer-node--temp': node.isTemp, 'explorer-node--drag-target': isDragTargetFolder}\" @dragover.prevent @dragenter.stop=\"node.noDrop || setDragTarget(node)\" @dragleave.stop=\"isDragTarget && setDragTarget()\" @drop.prevent.stop=\"onDrop\" @contextmenu=\"onContextMenu\">\n    <div class=\"explorer-node__item-editor\" v-if=\"isEditing\" :style=\"{paddingLeft: leftPadding}\" draggable=\"true\" @dragstart.stop.prevent>\n      <input type=\"text\" class=\"text-input\" v-focus @blur=\"submitEdit()\" @keydown.stop @keydown.enter=\"submitEdit()\" @keydown.esc.stop=\"submitEdit(true)\" v-model=\"editingNodeName\">\n    </div>\n    <div class=\"explorer-node__item\" v-else :style=\"{paddingLeft: leftPadding}\" @click=\"select()\" draggable=\"true\" @dragstart.stop=\"setDragSourceId\" @dragend.stop=\"setDragTarget()\">\n      {{node.item.name}}\n      <icon-provider class=\"explorer-node__location\" v-for=\"location in node.locations\" :key=\"location.id\" :provider-id=\"location.providerId\"></icon-provider>\n    </div>\n    <div class=\"explorer-node__children\" v-if=\"node.isFolder && isOpen\">\n      <explorer-node v-for=\"node in node.folders\" :key=\"node.item.id\" :node=\"node\" :depth=\"depth + 1\"></explorer-node>\n      <div v-if=\"newChild\" class=\"explorer-node__new-child\" :class=\"{'explorer-node__new-child--folder': newChild.isFolder}\" :style=\"{paddingLeft: childLeftPadding}\">\n        <input type=\"text\" class=\"text-input\" v-focus @blur=\"submitNewChild()\" @keydown.stop @keydown.enter=\"submitNewChild()\" @keydown.esc.stop=\"submitNewChild(true)\" v-model.trim=\"newChildName\">\n      </div>\n      <explorer-node v-for=\"node in node.files\" :key=\"node.item.id\" :node=\"node\" :depth=\"depth + 1\"></explorer-node>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapMutations, mapActions } from 'vuex';\nimport workspaceSvc from '../services/workspaceSvc';\nimport explorerSvc from '../services/explorerSvc';\nimport store from '../store';\nimport badgeSvc from '../services/badgeSvc';\n\nexport default {\n  name: 'explorer-node', // Required for recursivity\n  props: ['node', 'depth'],\n  data: () => ({\n    editingValue: '',\n  }),\n  computed: {\n    leftPadding() {\n      return `${this.depth * 15}px`;\n    },\n    childLeftPadding() {\n      return `${(this.depth + 1) * 15}px`;\n    },\n    isSelected() {\n      return store.getters['explorer/selectedNode'] === this.node;\n    },\n    isEditing() {\n      return store.getters['explorer/editingNode'] === this.node;\n    },\n    isDragTarget() {\n      return store.getters['explorer/dragTargetNode'] === this.node;\n    },\n    isDragTargetFolder() {\n      return store.getters['explorer/dragTargetNodeFolder'] === this.node;\n    },\n    isOpen() {\n      return store.state.explorer.openNodes[this.node.item.id] || this.node.isRoot;\n    },\n    newChild() {\n      return store.getters['explorer/newChildNodeParent'] === this.node\n        && store.state.explorer.newChildNode;\n    },\n    newChildName: {\n      get() {\n        return store.state.explorer.newChildNode.item.name;\n      },\n      set(value) {\n        store.commit('explorer/setNewItemName', value);\n      },\n    },\n    editingNodeName: {\n      get() {\n        return store.getters['explorer/editingNode'].item.name;\n      },\n      set(value) {\n        this.editingValue = value.trim();\n      },\n    },\n  },\n  methods: {\n    ...mapMutations('explorer', [\n      'setEditingId',\n    ]),\n    ...mapActions('explorer', [\n      'setDragTarget',\n    ]),\n    select(id = this.node.item.id, doOpen = true) {\n      const node = store.getters['explorer/nodeMap'][id];\n      if (!node) {\n        return false;\n      }\n      store.commit('explorer/setSelectedId', id);\n      if (doOpen) {\n        // Prevent from freezing the UI while loading the file\n        setTimeout(() => {\n          if (node.isFolder) {\n            store.commit('explorer/toggleOpenNode', id);\n          } else if (store.state.file.currentId !== id) {\n            store.commit('file/setCurrentId', id);\n            badgeSvc.addBadge('switchFile');\n          }\n        }, 10);\n      }\n      return true;\n    },\n    async submitNewChild(cancel) {\n      const { newChildNode } = store.state.explorer;\n      if (!cancel && !newChildNode.isNil && newChildNode.item.name) {\n        try {\n          if (newChildNode.isFolder) {\n            const item = await workspaceSvc.storeItem(newChildNode.item);\n            this.select(item.id);\n            badgeSvc.addBadge('createFolder');\n          } else {\n            const item = await workspaceSvc.createFile(newChildNode.item);\n            this.select(item.id);\n            badgeSvc.addBadge('createFile');\n          }\n        } catch (e) {\n          // Cancel\n        }\n      }\n      store.commit('explorer/setNewItem', null);\n    },\n    async submitEdit(cancel) {\n      const { item, isFolder } = store.getters['explorer/editingNode'];\n      const value = this.editingValue;\n      this.setEditingId(null);\n      if (!cancel && item.id && value && item.name !== value) {\n        try {\n          await workspaceSvc.storeItem({\n            ...item,\n            name: value,\n          });\n          badgeSvc.addBadge(isFolder ? 'renameFolder' : 'renameFile');\n        } catch (e) {\n          // Cancel\n        }\n      }\n    },\n    setDragSourceId(evt) {\n      if (this.node.noDrag) {\n        evt.preventDefault();\n        return;\n      }\n      store.commit('explorer/setDragSourceId', this.node.item.id);\n      // Fix for Firefox\n      // See https://stackoverflow.com/a/3977637/1333165\n      evt.dataTransfer.setData('Text', '');\n    },\n    onDrop() {\n      const sourceNode = store.getters['explorer/dragSourceNode'];\n      const targetNode = store.getters['explorer/dragTargetNodeFolder'];\n      this.setDragTarget();\n      if (!sourceNode.isNil\n        && !targetNode.isNil\n        && sourceNode.item.id !== targetNode.item.id\n      ) {\n        workspaceSvc.storeItem({\n          ...sourceNode.item,\n          parentId: targetNode.item.id,\n        });\n        badgeSvc.addBadge(sourceNode.isFolder ? 'moveFolder' : 'moveFile');\n      }\n    },\n    async onContextMenu(evt) {\n      if (this.select(undefined, false)) {\n        evt.preventDefault();\n        evt.stopPropagation();\n        const item = await store.dispatch('contextMenu/open', {\n          coordinates: {\n            left: evt.clientX,\n            top: evt.clientY,\n          },\n          items: [{\n            name: 'New file',\n            disabled: !this.node.isFolder || this.node.isTrash,\n            perform: () => explorerSvc.newItem(false),\n          }, {\n            name: 'New folder',\n            disabled: !this.node.isFolder || this.node.isTrash || this.node.isTemp,\n            perform: () => explorerSvc.newItem(true),\n          }, {\n            type: 'separator',\n          }, {\n            name: 'Rename',\n            disabled: this.node.isTrash || this.node.isTemp,\n            perform: () => this.setEditingId(this.node.item.id),\n          }, {\n            name: 'Delete',\n            perform: () => explorerSvc.deleteItem(),\n          }],\n        });\n        if (item) {\n          item.perform();\n        }\n      }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n$item-font-size: 14px;\n\n.explorer-node--drag-target {\n  background-color: rgba(0, 128, 255, 0.2);\n}\n\n.explorer-node__item {\n  position: relative;\n  cursor: pointer;\n  font-size: $item-font-size;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  padding-right: 5px;\n\n  .explorer-node--selected > & {\n    background-color: rgba(0, 0, 0, 0.2);\n\n    .explorer__tree:focus & {\n      background-color: #39f;\n      color: #fff;\n    }\n  }\n\n  .explorer__tree--new-item & {\n    opacity: 0.33;\n  }\n\n  .explorer-node__location {\n    float: right;\n    width: 18px;\n    height: 18px;\n    margin: 2px 1px;\n  }\n}\n\n.explorer-node--trash,\n.explorer-node--temp {\n  color: rgba(0, 0, 0, 0.5);\n}\n\n.explorer-node--folder > .explorer-node__item,\n.explorer-node--folder > .explorer-node__item-editor,\n.explorer-node__new-child--folder {\n  &::before {\n    content: '▹';\n    position: absolute;\n    margin-left: -13px;\n  }\n}\n\n.explorer-node--folder.explorer-node--open > .explorer-node__item,\n.explorer-node--folder.explorer-node--open > .explorer-node__item-editor {\n  &::before {\n    content: '▾';\n  }\n}\n\n$new-child-height: 25px;\n\n.explorer-node__item-editor,\n.explorer-node__new-child {\n  padding: 1px 10px;\n\n  .text-input {\n    font-size: $item-font-size;\n    padding: 2px;\n    height: $new-child-height;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/FindReplace.vue",
    "content": "<template>\n  <div class=\"find-replace\" @keydown.esc.stop=\"onEscape\">\n    <button class=\"find-replace__close-button button not-tabbable\" @click=\"close()\" v-title=\"'Close'\">\n      <icon-close></icon-close>\n    </button>\n    <div class=\"find-replace__row\">\n      <input type=\"text\" class=\"find-replace__text-input find-replace__text-input--find text-input\" @keydown.enter=\"find('forward')\" v-model=\"findText\">\n      <div class=\"find-replace__find-stats\">\n        {{findPosition}} of {{findCount}}\n      </div>\n      <div class=\"flex flex--row flex--space-between\">\n        <div class=\"flex flex--row\">\n          <button class=\"find-replace__button find-replace__button--find-option button\" :class=\"{'find-replace__button--on': findCaseSensitive}\" @click=\"findCaseSensitive = !findCaseSensitive\" title=\"Case sensitive\">Aa</button>\n          <button class=\"find-replace__button find-replace__button--find-option button\" :class=\"{'find-replace__button--on': findUseRegexp}\" @click=\"findUseRegexp = !findUseRegexp\" title=\"Regular expression\">.<sup>⁕</sup></button>\n        </div>\n        <div class=\"flex flex--row\">\n          <button class=\"find-replace__button button\" @click=\"find('backward')\">Previous</button>\n          <button class=\"find-replace__button button\" @click=\"find('forward')\">Next</button>\n        </div>\n      </div>\n    </div>\n    <div v-if=\"type === 'replace'\">\n      <div class=\"find-replace__row\">\n        <input type=\"text\" class=\"find-replace__text-input find-replace__text-input--replace text-input\" @keydown.enter=\"replace\" v-model=\"replaceText\">\n      </div>\n      <div class=\"find-replace__row flex flex--row flex--end\">\n        <button class=\"find-replace__button button\" @click=\"replace\">Replace</button>\n        <button class=\"find-replace__button button\" @click=\"replaceAll\">All</button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState } from 'vuex';\nimport editorSvc from '../services/editorSvc';\nimport cledit from '../services/editor/cledit';\nimport store from '../store';\nimport EditorClassApplier from './common/EditorClassApplier';\n\nconst accessor = (fieldName, setterName) => ({\n  get() {\n    return store.state.findReplace[fieldName];\n  },\n  set(value) {\n    store.commit(`findReplace/${setterName}`, value);\n  },\n});\n\nconst computedLayoutSetting = key => ({\n  get() {\n    return store.getters['data/layoutSettings'][key];\n  },\n  set(value) {\n    store.dispatch('data/patchLayoutSettings', {\n      [key]: value,\n    });\n  },\n});\n\nclass DynamicClassApplier {\n  constructor(cssClass, offset, silent) {\n    this.startMarker = new cledit.Marker(offset.start);\n    this.endMarker = new cledit.Marker(offset.end);\n    editorSvc.clEditor.addMarker(this.startMarker);\n    editorSvc.clEditor.addMarker(this.endMarker);\n    if (!silent) {\n      this.classApplier = new EditorClassApplier(\n        [`find-replace-${this.startMarker.id}`, cssClass],\n        () => ({\n          start: this.startMarker.offset,\n          end: this.endMarker.offset,\n        }),\n      );\n    }\n  }\n\n  clean = () => {\n    editorSvc.clEditor.removeMarker(this.startMarker);\n    editorSvc.clEditor.removeMarker(this.endMarker);\n    if (this.classApplier) {\n      this.classApplier.stop();\n    }\n  }\n}\n\nexport default {\n  data: () => ({\n    findCount: 0,\n    findPosition: 0,\n  }),\n  computed: {\n    ...mapState('findReplace', [\n      'type',\n      'lastOpen',\n    ]),\n    findText: accessor('findText', 'setFindText'),\n    replaceText: accessor('replaceText', 'setReplaceText'),\n    findCaseSensitive: computedLayoutSetting('findCaseSensitive'),\n    findUseRegexp: computedLayoutSetting('findUseRegexp'),\n  },\n  methods: {\n    highlightOccurrences() {\n      const oldClassAppliers = {};\n      Object.entries(this.classAppliers).forEach(([, classApplier]) => {\n        const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`;\n        oldClassAppliers[newKey] = classApplier;\n      });\n      const offsetList = [];\n      this.classAppliers = {};\n      if (this.state !== 'destroyed' && this.findText) {\n        try {\n          this.searchRegex = this.findText;\n          if (!this.findUseRegexp) {\n            this.searchRegex = this.searchRegex.replace(/[-[\\]/{}()*+?.\\\\^$|]/g, '\\\\$&');\n          }\n          this.replaceRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'm' : 'mi');\n          this.searchRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'gm' : 'gmi');\n          editorSvc.clEditor.getContent().replace(this.searchRegex, (...params) => {\n            const match = params[0];\n            const offset = params[params.length - 2];\n            offsetList.push({\n              start: offset,\n              end: offset + match.length,\n            });\n          });\n          offsetList.forEach((offset, i) => {\n            const key = `${offset.start}:${offset.end}`;\n            this.classAppliers[key] = oldClassAppliers[key] || new DynamicClassApplier(\n              'find-replace-highlighting',\n              offset,\n              i > 200,\n            );\n          });\n        } catch (e) {\n          // Ignore\n        }\n        if (this.state !== 'created') {\n          this.find('selection');\n          this.state = 'created';\n        }\n      }\n      Object.entries(oldClassAppliers).forEach(([key, classApplier]) => {\n        if (!this.classAppliers[key]) {\n          classApplier.clean();\n          if (classApplier === this.selectedClassApplier) {\n            this.selectedClassApplier.child.clean();\n            this.selectedClassApplier = null;\n          }\n        }\n      });\n      this.findCount = offsetList.length;\n    },\n    unselectClassApplier() {\n      if (this.selectedClassApplier) {\n        this.selectedClassApplier.child.clean();\n        this.selectedClassApplier.child = null;\n        this.selectedClassApplier = null;\n      }\n      this.findPosition = 0;\n    },\n    find(mode = 'forward') {\n      const { selectedClassApplier } = this;\n      this.unselectClassApplier();\n      const { selectionMgr } = editorSvc.clEditor;\n      const startOffset = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd);\n      const endOffset = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd);\n      const keys = Object.keys(this.classAppliers);\n      const finder = checker => (key) => {\n        if (checker(this.classAppliers[key]) && selectedClassApplier !== this.classAppliers[key]) {\n          this.selectedClassApplier = this.classAppliers[key];\n          return true;\n        }\n        return false;\n      };\n      if (mode === 'backward') {\n        this.selectedClassApplier = this.classAppliers[keys[keys.length - 1]];\n        keys.reverse().some(finder(classApplier => classApplier.startMarker.offset <= startOffset));\n      } else if (mode === 'selection') {\n        keys.some(finder(classApplier => classApplier.startMarker.offset === startOffset &&\n          classApplier.endMarker.offset === endOffset));\n      } else if (mode === 'forward') {\n        this.selectedClassApplier = this.classAppliers[keys[0]];\n        keys.some(finder(classApplier => classApplier.endMarker.offset >= endOffset));\n      }\n      if (this.selectedClassApplier) {\n        selectionMgr.setSelectionStartEnd(\n          this.selectedClassApplier.startMarker.offset,\n          this.selectedClassApplier.endMarker.offset,\n        );\n        this.selectedClassApplier.child = new DynamicClassApplier('find-replace-selection', {\n          start: this.selectedClassApplier.startMarker.offset,\n          end: this.selectedClassApplier.endMarker.offset,\n        });\n        selectionMgr.updateCursorCoordinates(this.$el.parentNode.clientHeight);\n        // Deduce the findPosition\n        Object.keys(this.classAppliers).forEach((key, i) => {\n          if (this.selectedClassApplier !== this.classAppliers[key]) {\n            return false;\n          }\n          this.findPosition = i + 1;\n          return true;\n        });\n      }\n    },\n    replace() {\n      if (this.searchRegex) {\n        if (!this.selectedClassApplier) {\n          this.find();\n          return;\n        }\n        editorSvc.clEditor.replaceAll(\n          this.replaceRegex,\n          this.replaceText,\n          this.selectedClassApplier.startMarker.offset,\n        );\n        this.$nextTick(() => this.find());\n      }\n    },\n    replaceAll() {\n      if (this.searchRegex) {\n        editorSvc.clEditor.replaceAll(this.searchRegex, this.replaceText);\n      }\n    },\n    close() {\n      store.commit('findReplace/setType');\n    },\n    onEscape() {\n      editorSvc.clEditor.focus();\n    },\n  },\n  mounted() {\n    this.classAppliers = {};\n\n    // Highlight occurences\n    this.debouncedHighlightOccurrences = cledit.Utils.debounce(\n      () => this.highlightOccurrences(),\n      25,\n    );\n    // Refresh highlighting when find text changes or changing options\n    this.$watch(() => this.findText, this.debouncedHighlightOccurrences);\n    this.$watch(() => this.findCaseSensitive, this.debouncedHighlightOccurrences);\n    this.$watch(() => this.findUseRegexp, this.debouncedHighlightOccurrences);\n    // Refresh highlighting when content changes\n    editorSvc.clEditor.on('contentChanged', this.debouncedHighlightOccurrences);\n\n    // Last open changes trigger focus on text input and find occurence in selection\n    this.$watch(() => this.lastOpen, () => {\n      const elt = this.$el.querySelector(`.find-replace__text-input--${this.type}`);\n      elt.focus();\n      elt.setSelectionRange(0, this[`${this.type}Text`].length);\n      // Highlight and find in selection\n      this.state = null;\n      this.debouncedHighlightOccurrences();\n    }, {\n      immediate: true,\n    });\n\n    // Close on escape\n    this.onKeyup = (evt) => {\n      if (evt.which === 27) {\n        // Esc key\n        store.commit('findReplace/setType');\n      }\n    };\n    window.addEventListener('keyup', this.onKeyup);\n\n    // Unselect class applier when focus is out of the panel\n    this.onFocusIn = () => this.$el.contains(document.activeElement) ||\n      setTimeout(() => this.unselectClassApplier(), 15);\n    window.addEventListener('focusin', this.onFocusIn);\n  },\n  destroyed() {\n    // Unregister listeners\n    editorSvc.clEditor.off('contentChanged', this.debouncedHighlightOccurrences);\n    window.removeEventListener('keyup', this.onKeyup);\n    window.removeEventListener('focusin', this.onFocusIn);\n    this.state = 'destroyed';\n    this.debouncedHighlightOccurrences();\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.find-replace {\n  padding: 0 35px 0 25px;\n}\n\n.find-replace__row {\n  margin: 10px 0;\n}\n\n.find-replace__button {\n  font-size: 15px;\n  padding: 0 8px;\n  line-height: 28px;\n  height: 28px;\n}\n\n.find-replace__button--find-option {\n  padding: 0;\n  width: 28px;\n  font-weight: 600;\n  letter-spacing: -0.025em;\n  color: rgba(0, 0, 0, 0.25);\n  text-transform: none;\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.25);\n  }\n}\n\n.find-replace__button--on {\n  color: rgba(0, 0, 0, 0.67);\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.67);\n  }\n}\n\n.find-replace__text-input {\n  border: 1px solid transparent;\n  padding: 2px 5px;\n  height: 32px;\n\n  &:focus {\n    border-color: $link-color;\n  }\n}\n\n.find-replace__close-button {\n  position: absolute;\n  top: 5px;\n  right: 5px;\n  width: 25px;\n  height: 25px;\n  padding: 2px;\n  color: rgba(0, 0, 0, 0.5);\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.75);\n  }\n}\n\n.find-replace__find-stats {\n  text-align: right;\n  font-size: 0.75em;\n  opacity: 0.6;\n}\n\n.find-replace-highlighting {\n  background-color: $highlighting-color;\n  color: $editor-color-light !important;\n}\n\n.find-replace-selection {\n  background-color: $selection-highlighting-color;\n}\n</style>\n"
  },
  {
    "path": "src/components/Layout.vue",
    "content": "<template>\n  <div class=\"layout\" :class=\"{'layout--revision': revisionContent}\">\n    <div class=\"layout__panel flex flex--row\" :class=\"{'flex--end': styles.showSideBar}\">\n      <div class=\"layout__panel layout__panel--explorer\" v-show=\"styles.showExplorer\" :aria-hidden=\"!styles.showExplorer\" :style=\"{width: styles.layoutOverflow ? '100%' : constants.explorerWidth + 'px'}\">\n        <explorer></explorer>\n      </div>\n      <div class=\"layout__panel flex flex--column\" tour-step-anchor=\"welcome,end\" :style=\"{width: styles.innerWidth + 'px'}\">\n        <div class=\"layout__panel layout__panel--navigation-bar\" v-show=\"styles.showNavigationBar\" :style=\"{height: constants.navigationBarHeight + 'px'}\">\n          <navigation-bar></navigation-bar>\n        </div>\n        <div class=\"layout__panel flex flex--row\" :style=\"{height: styles.innerHeight + 'px'}\">\n          <div class=\"layout__panel layout__panel--editor\" v-show=\"styles.showEditor\" :style=\"{width: (styles.editorWidth + styles.editorGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}\">\n            <div class=\"gutter\" :style=\"{left: styles.editorGutterLeft + 'px'}\">\n              <div class=\"gutter__background\" v-if=\"styles.editorGutterWidth\" :style=\"{width: styles.editorGutterWidth + 'px'}\"></div>\n            </div>\n            <editor></editor>\n            <div class=\"gutter\" :style=\"{left: styles.editorGutterLeft + 'px'}\">\n              <sticky-comment v-if=\"styles.editorGutterWidth && stickyComment === 'top'\"></sticky-comment>\n              <current-discussion v-if=\"styles.editorGutterWidth\"></current-discussion>\n            </div>\n          </div>\n          <div class=\"layout__panel layout__panel--button-bar\" v-show=\"styles.showEditor\" :style=\"{width: constants.buttonBarWidth + 'px'}\">\n            <button-bar></button-bar>\n          </div>\n          <div class=\"layout__panel layout__panel--preview\" v-show=\"styles.showPreview\" :style=\"{width: (styles.previewWidth + styles.previewGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}\">\n            <div class=\"gutter\" :style=\"{left: styles.previewGutterLeft + 'px'}\">\n              <div class=\"gutter__background\" v-if=\"styles.previewGutterWidth\" :style=\"{width: styles.previewGutterWidth + 'px'}\"></div>\n            </div>\n            <preview></preview>\n            <div class=\"gutter\" :style=\"{left: styles.previewGutterLeft + 'px'}\">\n              <sticky-comment v-if=\"styles.previewGutterWidth && stickyComment === 'top'\"></sticky-comment>\n              <current-discussion v-if=\"styles.previewGutterWidth\"></current-discussion>\n            </div>\n          </div>\n          <div class=\"layout__panel layout__panel--find-replace\" v-if=\"showFindReplace\">\n            <find-replace></find-replace>\n          </div>\n        </div>\n        <div class=\"layout__panel layout__panel--status-bar\" v-show=\"styles.showStatusBar\" :style=\"{height: constants.statusBarHeight + 'px'}\">\n          <status-bar></status-bar>\n        </div>\n      </div>\n      <div class=\"layout__panel layout__panel--side-bar\" v-show=\"styles.showSideBar\" :style=\"{width: styles.layoutOverflow ? '100%' : constants.sideBarWidth + 'px'}\">\n        <side-bar></side-bar>\n      </div>\n    </div>\n    <tour v-if=\"!light && !layoutSettings.welcomeTourFinished\"></tour>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters, mapActions } from 'vuex';\nimport NavigationBar from './NavigationBar';\nimport ButtonBar from './ButtonBar';\nimport StatusBar from './StatusBar';\nimport Explorer from './Explorer';\nimport SideBar from './SideBar';\nimport Editor from './Editor';\nimport Preview from './Preview';\nimport Tour from './Tour';\nimport StickyComment from './gutters/StickyComment';\nimport CurrentDiscussion from './gutters/CurrentDiscussion';\nimport FindReplace from './FindReplace';\nimport editorSvc from '../services/editorSvc';\nimport markdownConversionSvc from '../services/markdownConversionSvc';\nimport store from '../store';\n\nexport default {\n  components: {\n    NavigationBar,\n    ButtonBar,\n    StatusBar,\n    Explorer,\n    SideBar,\n    Editor,\n    Preview,\n    Tour,\n    StickyComment,\n    CurrentDiscussion,\n    FindReplace,\n  },\n  computed: {\n    ...mapState([\n      'light',\n    ]),\n    ...mapState('content', [\n      'revisionContent',\n    ]),\n    ...mapState('discussion', [\n      'stickyComment',\n    ]),\n    ...mapGetters('layout', [\n      'constants',\n      'styles',\n    ]),\n    ...mapGetters('data', [\n      'layoutSettings',\n    ]),\n    showFindReplace() {\n      return !!store.state.findReplace.type;\n    },\n  },\n  methods: {\n    ...mapActions('layout', [\n      'updateBodySize',\n    ]),\n    saveSelection: () => editorSvc.saveSelection(true),\n  },\n  created() {\n    markdownConversionSvc.init(); // Needs to be inited before mount\n    this.updateBodySize();\n    window.addEventListener('resize', this.updateBodySize);\n    window.addEventListener('keyup', this.saveSelection);\n    window.addEventListener('mouseup', this.saveSelection);\n    window.addEventListener('focusin', this.saveSelection);\n    window.addEventListener('contextmenu', this.saveSelection);\n  },\n  mounted() {\n    const editorElt = this.$el.querySelector('.editor__inner');\n    const previewElt = this.$el.querySelector('.preview__inner-2');\n    const tocElt = this.$el.querySelector('.toc__inner');\n    editorSvc.init(editorElt, previewElt, tocElt);\n\n    // Focus on the editor every time reader mode is disabled\n    const focus = () => {\n      if (this.styles.showEditor) {\n        editorSvc.clEditor.focus();\n      }\n    };\n    setTimeout(focus, 100);\n    this.$watch(() => this.styles.showEditor, focus);\n  },\n  destroyed() {\n    window.removeEventListener('resize', this.updateStyle);\n    window.removeEventListener('keyup', this.saveSelection);\n    window.removeEventListener('mouseup', this.saveSelection);\n    window.removeEventListener('focusin', this.saveSelection);\n    window.removeEventListener('contextmenu', this.saveSelection);\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.layout {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n}\n\n.layout__panel {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  flex: none;\n  overflow: hidden;\n}\n\n.layout__panel--navigation-bar {\n  background-color: $navbar-bg;\n}\n\n.layout__panel--status-bar {\n  background-color: #007acc;\n}\n\n.layout__panel--editor {\n  background-color: $editor-background-light;\n\n  .app--dark & {\n    background-color: $editor-background-dark;\n  }\n\n  .gutter__background,\n  .comment-list__current-discussion,\n  .sticky-comment,\n  .current-discussion {\n    background-color: mix(#000, $editor-background-light, 6.7%);\n\n    .app--dark & {\n      background-color: mix(#fff, $editor-background-dark, 6.7%);\n    }\n  }\n}\n\n$preview-background-light: #f3f3f3;\n$preview-background-dark: #252525;\n\n.layout__panel--preview,\n.layout__panel--button-bar {\n  background-color: $preview-background-light;\n\n  .app--dark & {\n    background-color: $preview-background-dark;\n  }\n}\n\n.layout__panel--preview {\n  .gutter__background,\n  .comment-list__current-discussion,\n  .sticky-comment,\n  .current-discussion {\n    background-color: mix(#000, $preview-background-light, 6.7%);\n  }\n}\n\n.layout__panel--explorer,\n.layout__panel--side-bar {\n  background-color: #ddd;\n}\n\n.layout__panel--find-replace {\n  background-color: #e6e6e6;\n  position: absolute;\n  left: 0;\n  bottom: 0;\n  width: 300px;\n  height: auto;\n  border-top-right-radius: $border-radius-base;\n}\n</style>\n"
  },
  {
    "path": "src/components/Modal.vue",
    "content": "<template>\n  <div class=\"modal\" v-if=\"config\" @keydown.esc.stop=\"onEscape\" @keydown.tab=\"onTab\" @focusin=\"onFocusInOut\" @focusout=\"onFocusInOut\">\n    <div class=\"modal__sponsor-banner\" v-if=\"!isSponsor\">\n      StackEdit is <a class=\"not-tabbable\" target=\"_blank\" href=\"https://github.com/benweet/stackedit/\">open source</a>, please consider\n      <a class=\"not-tabbable\" href=\"javascript:void(0)\" @click=\"sponsor\">sponsoring</a> for just $5.\n    </div>\n    <component v-if=\"currentModalComponent\" :is=\"currentModalComponent\"></component>\n    <modal-inner v-else aria-label=\"Dialog\">\n      <div class=\"modal__content\" v-html=\"simpleModal.contentHtml(config)\"></div>\n      <div class=\"modal__button-bar\">\n        <button class=\"button\" v-if=\"simpleModal.rejectText\" @click=\"config.reject()\">{{simpleModal.rejectText}}</button>\n        <button class=\"button button--resolve\" v-if=\"simpleModal.resolveText\" @click=\"config.resolve()\">{{simpleModal.resolveText}}</button>\n      </div>\n    </modal-inner>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport simpleModals from '../data/simpleModals';\nimport editorSvc from '../services/editorSvc';\nimport syncSvc from '../services/syncSvc';\nimport googleHelper from '../services/providers/helpers/googleHelper';\nimport store from '../store';\n\nimport ModalInner from './modals/common/ModalInner';\nimport FilePropertiesModal from './modals/FilePropertiesModal';\nimport SettingsModal from './modals/SettingsModal';\nimport TemplatesModal from './modals/TemplatesModal';\nimport AboutModal from './modals/AboutModal';\nimport HtmlExportModal from './modals/HtmlExportModal';\nimport PdfExportModal from './modals/PdfExportModal';\nimport PandocExportModal from './modals/PandocExportModal';\nimport LinkModal from './modals/LinkModal';\nimport ImageModal from './modals/ImageModal';\nimport SyncManagementModal from './modals/SyncManagementModal';\nimport PublishManagementModal from './modals/PublishManagementModal';\nimport WorkspaceManagementModal from './modals/WorkspaceManagementModal';\nimport AccountManagementModal from './modals/AccountManagementModal';\nimport BadgeManagementModal from './modals/BadgeManagementModal';\nimport SponsorModal from './modals/SponsorModal';\n\n// Providers\nimport GooglePhotoModal from './modals/providers/GooglePhotoModal';\nimport GoogleDriveAccountModal from './modals/providers/GoogleDriveAccountModal';\nimport GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';\nimport GoogleDriveWorkspaceModal from './modals/providers/GoogleDriveWorkspaceModal';\nimport GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';\nimport DropboxAccountModal from './modals/providers/DropboxAccountModal';\nimport DropboxSaveModal from './modals/providers/DropboxSaveModal';\nimport DropboxPublishModal from './modals/providers/DropboxPublishModal';\nimport GithubAccountModal from './modals/providers/GithubAccountModal';\nimport GithubOpenModal from './modals/providers/GithubOpenModal';\nimport GithubSaveModal from './modals/providers/GithubSaveModal';\nimport GithubWorkspaceModal from './modals/providers/GithubWorkspaceModal';\nimport GithubPublishModal from './modals/providers/GithubPublishModal';\nimport GistSyncModal from './modals/providers/GistSyncModal';\nimport GistPublishModal from './modals/providers/GistPublishModal';\nimport GitlabAccountModal from './modals/providers/GitlabAccountModal';\nimport GitlabOpenModal from './modals/providers/GitlabOpenModal';\nimport GitlabPublishModal from './modals/providers/GitlabPublishModal';\nimport GitlabSaveModal from './modals/providers/GitlabSaveModal';\nimport GitlabWorkspaceModal from './modals/providers/GitlabWorkspaceModal';\nimport WordpressPublishModal from './modals/providers/WordpressPublishModal';\nimport BloggerPublishModal from './modals/providers/BloggerPublishModal';\nimport BloggerPagePublishModal from './modals/providers/BloggerPagePublishModal';\nimport ZendeskAccountModal from './modals/providers/ZendeskAccountModal';\nimport ZendeskPublishModal from './modals/providers/ZendeskPublishModal';\nimport CouchdbWorkspaceModal from './modals/providers/CouchdbWorkspaceModal';\nimport CouchdbCredentialsModal from './modals/providers/CouchdbCredentialsModal';\n\nconst getTabbables = container => container.querySelectorAll('a[href], button, .textfield, input[type=checkbox]')\n  // Filter enabled and visible element\n  .cl_filter(el => !el.disabled && el.offsetParent !== null && !el.classList.contains('not-tabbable'));\n\nexport default {\n  components: {\n    ModalInner,\n    FilePropertiesModal,\n    SettingsModal,\n    TemplatesModal,\n    AboutModal,\n    HtmlExportModal,\n    PdfExportModal,\n    PandocExportModal,\n    LinkModal,\n    ImageModal,\n    SyncManagementModal,\n    PublishManagementModal,\n    WorkspaceManagementModal,\n    AccountManagementModal,\n    BadgeManagementModal,\n    SponsorModal,\n    // Providers\n    GooglePhotoModal,\n    GoogleDriveAccountModal,\n    GoogleDriveSaveModal,\n    GoogleDriveWorkspaceModal,\n    GoogleDrivePublishModal,\n    DropboxAccountModal,\n    DropboxSaveModal,\n    DropboxPublishModal,\n    GithubAccountModal,\n    GithubOpenModal,\n    GithubSaveModal,\n    GithubWorkspaceModal,\n    GithubPublishModal,\n    GistSyncModal,\n    GistPublishModal,\n    GitlabAccountModal,\n    GitlabOpenModal,\n    GitlabPublishModal,\n    GitlabSaveModal,\n    GitlabWorkspaceModal,\n    WordpressPublishModal,\n    BloggerPublishModal,\n    BloggerPagePublishModal,\n    ZendeskAccountModal,\n    ZendeskPublishModal,\n    CouchdbWorkspaceModal,\n    CouchdbCredentialsModal,\n  },\n  computed: {\n    ...mapGetters([\n      'isSponsor',\n    ]),\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    currentModalComponent() {\n      if (this.config.type) {\n        let componentName = this.config.type[0].toUpperCase();\n        componentName += this.config.type.slice(1);\n        componentName += 'Modal';\n        if (this.$options.components[componentName]) {\n          return componentName;\n        }\n      }\n      return null;\n    },\n    simpleModal() {\n      return simpleModals[this.config.type] || {};\n    },\n  },\n  methods: {\n    async sponsor() {\n      try {\n        if (!store.getters['workspace/sponsorToken']) {\n          // User has to sign in\n          await store.dispatch('modal/open', 'signInForSponsorship');\n          await googleHelper.signin();\n          syncSvc.requestSync();\n        }\n        if (!store.getters.isSponsor) {\n          await store.dispatch('modal/open', 'sponsor');\n        }\n      } catch (e) { /* cancel */ }\n    },\n    onEscape() {\n      this.config.reject();\n      editorSvc.clEditor.focus();\n    },\n    onTab(evt) {\n      const tabbables = getTabbables(this.$el);\n      const firstTabbable = tabbables[0];\n      const lastTabbable = tabbables[tabbables.length - 1];\n      if (evt.shiftKey && firstTabbable === evt.target) {\n        evt.preventDefault();\n        lastTabbable.focus();\n      } else if (!evt.shiftKey && lastTabbable === evt.target) {\n        evt.preventDefault();\n        firstTabbable.focus();\n      }\n    },\n    onFocusInOut(evt) {\n      const { parentNode } = evt.target;\n      if (parentNode && parentNode.parentNode) {\n        // Focus effect\n        if (parentNode.classList.contains('form-entry__field')\n          && parentNode.parentNode.classList.contains('form-entry')) {\n          parentNode.parentNode.classList.toggle(\n            'form-entry--focused',\n            evt.type === 'focusin',\n          );\n        }\n      }\n    },\n  },\n  mounted() {\n    this.$watch(\n      () => this.config,\n      (isOpen) => {\n        if (isOpen) {\n          const tabbables = getTabbables(this.$el);\n          if (tabbables[0]) {\n            tabbables[0].focus();\n          }\n        }\n      },\n      { immediate: true },\n    );\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.modal {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(160, 160, 160, 0.5);\n  overflow: auto;\n\n  p {\n    line-height: 1.5;\n  }\n}\n\n.modal__sponsor-banner {\n  position: fixed;\n  z-index: 1;\n  width: 100%;\n  color: darken($error-color, 10%);\n  background-color: transparentize(lighten($error-color, 33%), 0.075);\n  font-size: 0.9em;\n  line-height: 1.33;\n  text-align: center;\n  padding: 0.25em 1em;\n}\n\n.modal__inner-1 {\n  margin: 0 auto;\n  width: 100%;\n  min-width: 320px;\n  max-width: 480px;\n}\n\n.modal__inner-2 {\n  margin: 40px 10px 100px;\n  background-color: #f8f8f8;\n  padding: 50px 50px 40px;\n  border-radius: $border-radius-base;\n  position: relative;\n  overflow: hidden;\n\n  &::before {\n    content: '';\n    position: absolute;\n    top: 0;\n    left: 0;\n    height: $border-radius-base;\n    width: 100%;\n    background-image: linear-gradient(to left, #ffd700, #ffd700 23%, #a5c700 27%, #a5c700 48%, #ff8a00 52%, #ff8a00 73%, #66aefd 77%);\n  }\n\n  &::after {\n    content: '';\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    height: $border-radius-base;\n    width: 100%;\n    background-image: linear-gradient(to right, #ffd700, #ffd700 23%, #a5c700 27%, #a5c700 48%, #ff8a00 52%, #ff8a00 73%, #66aefd 77%);\n  }\n}\n\n.modal__content > :first-child,\n.modal__content > .modal__image:first-child + * {\n  margin-top: 0;\n}\n\n.modal__image {\n  float: left;\n  width: 60px;\n  height: 60px;\n  margin: 1.5em 1.2em 0.5em 0;\n\n  & + *::after {\n    content: '';\n    display: block;\n    clear: both;\n  }\n}\n\n.modal__title {\n  font-weight: bold;\n  font-size: 1.5rem;\n  line-height: 1.4;\n  margin-top: 2.5rem;\n}\n\n.modal__sub-title {\n  opacity: 0.6;\n  font-size: 0.75rem;\n  margin-bottom: 1.5rem;\n}\n\n.modal__error {\n  color: #de2c00;\n}\n\n.modal__info {\n  background-color: $info-bg;\n  border-radius: $border-radius-base;\n  margin: 1.2em 0;\n  padding: 0.75em 1.25em;\n  font-size: 0.95em;\n  line-height: 1.6;\n\n  pre {\n    line-height: 1.5;\n  }\n}\n\n.modal__info--multiline {\n  padding-top: 0.1em;\n  padding-bottom: 0.1em;\n}\n\n.modal__button-bar {\n  margin-top: 2rem;\n  display: flex;\n  flex-direction: row;\n  justify-content: flex-end;\n}\n\n.form-entry {\n  margin: 1em 0;\n}\n\n.form-entry__label {\n  display: block;\n  font-size: 0.9rem;\n  color: #808080;\n\n  .form-entry--focused & {\n    color: darken($link-color, 10%);\n  }\n\n  .form-entry--error & {\n    color: darken($error-color, 10%);\n  }\n}\n\n.form-entry__label-info {\n  font-size: 0.75rem;\n}\n\n.form-entry__field {\n  border: 1px solid #b0b0b0;\n  border-radius: $border-radius-base;\n  position: relative;\n  overflow: hidden;\n\n  .form-entry--focused & {\n    border-color: $link-color;\n    box-shadow: 0 0 0 2.5px transparentize($link-color, 0.67);\n  }\n\n  .form-entry--error & {\n    border-color: $error-color;\n    box-shadow: 0 0 0 2.5px transparentize($error-color, 0.67);\n  }\n}\n\n.form-entry__actions {\n  text-align: right;\n  margin: 0.25em;\n}\n\n.form-entry__button {\n  width: 38px;\n  height: 38px;\n  padding: 6px;\n  display: inline-block;\n  background-color: transparent;\n  opacity: 0.75;\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n}\n\n.form-entry__radio,\n.form-entry__checkbox {\n  margin: 0.25em 1em;\n\n  input {\n    margin-right: 0.25em;\n  }\n}\n\n.form-entry__info {\n  font-size: 0.75em;\n  opacity: 0.67;\n  line-height: 1.4;\n  margin: 0.25em 0;\n}\n\n.tabs {\n  border-bottom: 1px solid $hr-color;\n  margin: 1em 0 2em;\n\n  &::after {\n    content: '';\n    display: block;\n    clear: both;\n  }\n}\n\n.tabs__tab {\n  width: 50%;\n  float: left;\n  text-align: center;\n  line-height: 1.4;\n  font-weight: 400;\n  font-size: 1.1em;\n}\n\n.tabs__tab > a {\n  width: 100%;\n  text-decoration: none;\n  padding: 0.67em 0.33em;\n  cursor: pointer;\n  border-bottom: 2px solid transparent;\n  border-top-left-radius: $border-radius-base;\n  border-top-right-radius: $border-radius-base;\n  color: $link-color;\n\n  &:hover,\n  &:focus {\n    background-color: rgba(0, 0, 0, 0.05);\n  }\n}\n\n.tabs__tab--active > a {\n  border-bottom: 2px solid $link-color;\n  color: inherit;\n}\n</style>\n"
  },
  {
    "path": "src/components/NavigationBar.vue",
    "content": "<template>\n  <nav class=\"navigation-bar\" :class=\"{'navigation-bar--editor': styles.showEditor && !revisionContent, 'navigation-bar--light': light}\">\n    <!-- Explorer -->\n    <div class=\"navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button\">\n      <button class=\"navigation-bar__button navigation-bar__button--close button\" v-if=\"light\" @click=\"close()\" v-title=\"'Close StackEdit'\"><icon-check-circle></icon-check-circle></button>\n      <button class=\"navigation-bar__button navigation-bar__button--explorer-toggler button\" v-else tour-step-anchor=\"explorer\" @click=\"toggleExplorer()\" v-title=\"'Toggle explorer'\"><icon-folder></icon-folder></button>\n    </div>\n    <!-- Side bar -->\n    <div class=\"navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button\">\n      <a class=\"navigation-bar__button navigation-bar__button--stackedit button\" v-if=\"light\" href=\"app\" target=\"_blank\" v-title=\"'Open StackEdit'\"><icon-provider provider-id=\"stackedit\"></icon-provider></a>\n      <button class=\"navigation-bar__button navigation-bar__button--stackedit button\" v-else tour-step-anchor=\"menu\" @click=\"toggleSideBar()\" v-title=\"'Toggle side bar'\"><icon-provider provider-id=\"stackedit\"></icon-provider></button>\n    </div>\n    <div class=\"navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--title flex flex--row\">\n      <!-- Spinner -->\n      <div class=\"navigation-bar__spinner\">\n        <div v-if=\"!offline && showSpinner\" class=\"spinner\"></div>\n        <icon-sync-off v-if=\"offline\"></icon-sync-off>\n      </div>\n      <!-- Title -->\n      <div class=\"navigation-bar__title navigation-bar__title--fake text-input\"></div>\n      <div class=\"navigation-bar__title navigation-bar__title--text text-input\" :style=\"{width: titleWidth + 'px'}\">{{title}}</div>\n      <input class=\"navigation-bar__title navigation-bar__title--input text-input\" :class=\"{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}\" :style=\"{width: titleWidth + 'px'}\" @focus=\"editTitle(true)\" @blur=\"editTitle(false)\" @keydown.enter=\"submitTitle(false)\" @keydown.esc.stop=\"submitTitle(true)\" @mouseenter=\"titleHover = true\" @mouseleave=\"titleHover = false\" v-model=\"title\">\n      <!-- Sync/Publish -->\n      <div class=\"flex flex--row\" :class=\"{'navigation-bar__hidden': styles.hideLocations}\">\n        <a class=\"navigation-bar__button navigation-bar__button--location button\" :class=\"{'navigation-bar__button--blink': location.id === currentLocation.id}\" v-for=\"location in syncLocations\" :key=\"location.id\" :href=\"location.url\" target=\"_blank\" v-title=\"'Synchronized location'\"><icon-provider :provider-id=\"location.providerId\"></icon-provider></a>\n        <button class=\"navigation-bar__button navigation-bar__button--sync button\" :disabled=\"!isSyncPossible || isSyncRequested || offline\" @click=\"requestSync\" v-title=\"'Synchronize now'\"><icon-sync></icon-sync></button>\n        <a class=\"navigation-bar__button navigation-bar__button--location button\" :class=\"{'navigation-bar__button--blink': location.id === currentLocation.id}\" v-for=\"location in publishLocations\" :key=\"location.id\" :href=\"location.url\" target=\"_blank\" v-title=\"'Publish location'\"><icon-provider :provider-id=\"location.providerId\"></icon-provider></a>\n        <button class=\"navigation-bar__button navigation-bar__button--publish button\" :disabled=\"!publishLocations.length || isPublishRequested || offline\" @click=\"requestPublish\" v-title=\"'Publish now'\"><icon-upload></icon-upload></button>\n      </div>\n      <!-- Revision -->\n      <div class=\"flex flex--row\" v-if=\"revisionContent\">\n        <button class=\"navigation-bar__button navigation-bar__button--revision navigation-bar__button--restore button\" @click=\"restoreRevision\">Restore</button>\n        <button class=\"navigation-bar__button navigation-bar__button--revision button\" @click=\"setRevisionContent()\" v-title=\"'Close revision'\"><icon-close></icon-close></button>\n      </div>\n    </div>\n    <div class=\"navigation-bar__inner navigation-bar__inner--edit-pagedownButtons\">\n      <button class=\"navigation-bar__button button\" @click=\"undo\" v-title=\"'Undo'\" :disabled=\"!canUndo\"><icon-undo></icon-undo></button>\n      <button class=\"navigation-bar__button button\" @click=\"redo\" v-title=\"'Redo'\" :disabled=\"!canRedo\"><icon-redo></icon-redo></button>\n      <div v-for=\"button in pagedownButtons\" :key=\"button.method\">\n        <button class=\"navigation-bar__button button\" v-if=\"button.method\" @click=\"pagedownClick(button.method)\" v-title=\"button.titleWithShortcut\">\n          <component :is=\"button.iconClass\"></component>\n        </button>\n        <div class=\"navigation-bar__spacer\" v-else></div>\n      </div>\n    </div>\n  </nav>\n</template>\n\n<script>\nimport { mapState, mapMutations, mapGetters, mapActions } from 'vuex';\nimport editorSvc from '../services/editorSvc';\nimport syncSvc from '../services/syncSvc';\nimport publishSvc from '../services/publishSvc';\nimport animationSvc from '../services/animationSvc';\nimport tempFileSvc from '../services/tempFileSvc';\nimport utils from '../services/utils';\nimport pagedownButtons from '../data/pagedownButtons';\nimport store from '../store';\nimport workspaceSvc from '../services/workspaceSvc';\nimport badgeSvc from '../services/badgeSvc';\n\n// According to mousetrap\nconst mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';\n\nconst getShortcut = (method) => {\n  let result = '';\n  Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => {\n    if (`${shortcut.method || shortcut}` === method) {\n      result = keys.split('+').map(key => key.toLowerCase()).map((key) => {\n        if (key === 'mod') {\n          return mod;\n        }\n        // Capitalize\n        return key && `${key[0].toUpperCase()}${key.slice(1)}`;\n      }).join('+');\n    }\n    return result;\n  });\n  return result && ` – ${result}`;\n};\n\nexport default {\n  data: () => ({\n    mounted: false,\n    title: '',\n    titleFocus: false,\n    titleHover: false,\n  }),\n  computed: {\n    ...mapState([\n      'light',\n      'offline',\n    ]),\n    ...mapState('queue', [\n      'isSyncRequested',\n      'isPublishRequested',\n      'currentLocation',\n    ]),\n    ...mapState('layout', [\n      'canUndo',\n      'canRedo',\n    ]),\n    ...mapState('content', [\n      'revisionContent',\n    ]),\n    ...mapGetters('layout', [\n      'styles',\n    ]),\n    ...mapGetters('syncLocation', {\n      syncLocations: 'current',\n    }),\n    ...mapGetters('publishLocation', {\n      publishLocations: 'current',\n    }),\n    pagedownButtons() {\n      return pagedownButtons.map(button => ({\n        ...button,\n        titleWithShortcut: `${button.title}${getShortcut(button.method)}`,\n        iconClass: `icon-${button.icon}`,\n      }));\n    },\n    isSyncPossible() {\n      return store.getters['workspace/syncToken'] ||\n        store.getters['syncLocation/current'].length;\n    },\n    showSpinner() {\n      return !store.state.queue.isEmpty;\n    },\n    titleWidth() {\n      if (!this.mounted) {\n        return 0;\n      }\n      this.titleFakeElt.textContent = this.title;\n      const width = this.titleFakeElt.getBoundingClientRect().width + 2; // 2px for the caret\n      return Math.min(width, this.styles.titleMaxWidth);\n    },\n    titleScrolling() {\n      const result = this.titleHover && !this.titleFocus;\n      if (this.titleInputElt) {\n        if (result) {\n          const scrollLeft = this.titleInputElt.scrollWidth - this.titleInputElt.offsetWidth;\n          animationSvc.animate(this.titleInputElt)\n            .scrollLeft(scrollLeft)\n            .duration(scrollLeft * 10)\n            .easing('inOut')\n            .start();\n        } else {\n          animationSvc.animate(this.titleInputElt)\n            .scrollLeft(0)\n            .start();\n        }\n      }\n      return result;\n    },\n    editCancelTrigger() {\n      const current = store.getters['file/current'];\n      return utils.serializeObject([\n        current.id,\n        current.name,\n      ]);\n    },\n  },\n  methods: {\n    ...mapMutations('content', [\n      'setRevisionContent',\n    ]),\n    ...mapActions('content', [\n      'restoreRevision',\n    ]),\n    ...mapActions('data', [\n      'toggleExplorer',\n      'toggleSideBar',\n    ]),\n    undo() {\n      return editorSvc.clEditor.undoMgr.undo();\n    },\n    redo() {\n      return editorSvc.clEditor.undoMgr.redo();\n    },\n    requestSync() {\n      if (this.isSyncPossible && !this.isSyncRequested) {\n        syncSvc.requestSync(true);\n      }\n    },\n    requestPublish() {\n      if (this.publishLocations.length && !this.isPublishRequested) {\n        publishSvc.requestPublish();\n      }\n    },\n    pagedownClick(name) {\n      if (store.getters['content/isCurrentEditable']) {\n        const text = editorSvc.clEditor.getContent();\n        editorSvc.pagedownEditor.uiManager.doClick(name);\n        if (text !== editorSvc.clEditor.getContent()) {\n          badgeSvc.addBadge('formatButtons');\n        }\n      }\n    },\n    async editTitle(toggle) {\n      this.titleFocus = toggle;\n      if (toggle) {\n        this.titleInputElt.setSelectionRange(0, this.titleInputElt.value.length);\n      } else {\n        const title = this.title.trim();\n        this.title = store.getters['file/current'].name;\n        if (title && this.title !== title) {\n          try {\n            await workspaceSvc.storeItem({\n              ...store.getters['file/current'],\n              name: title,\n            });\n            badgeSvc.addBadge('editCurrentFileName');\n          } catch (e) {\n            // Cancel\n          }\n        }\n      }\n    },\n    submitTitle(reset) {\n      if (reset) {\n        this.title = '';\n      }\n      this.titleInputElt.blur();\n    },\n    close() {\n      tempFileSvc.close();\n    },\n  },\n  created() {\n    this.$watch(\n      () => this.editCancelTrigger,\n      () => {\n        this.title = '';\n        this.editTitle(false);\n      },\n      { immediate: true },\n    );\n  },\n  mounted() {\n    this.titleFakeElt = this.$el.querySelector('.navigation-bar__title--fake');\n    this.titleInputElt = this.$el.querySelector('.navigation-bar__title--input');\n    this.mounted = true;\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.navigation-bar {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  padding-top: 4px;\n  overflow: hidden;\n}\n\n.navigation-bar__hidden {\n  display: none;\n}\n\n.navigation-bar__inner--left {\n  float: left;\n\n  &.navigation-bar__inner--button {\n    margin-right: 12px;\n  }\n}\n\n.navigation-bar__inner--right {\n  float: right;\n\n  /* prevent from seeing wrapped pagedownButtons */\n  margin-bottom: 20px;\n}\n\n.navigation-bar__inner--button {\n  margin: 0 4px;\n}\n\n.navigation-bar__inner--edit-pagedownButtons {\n  margin-left: 15px;\n\n  .navigation-bar__button,\n  .navigation-bar__spacer {\n    float: left;\n  }\n}\n\n.navigation-bar__inner--title * {\n  flex: none;\n}\n\n.navigation-bar__button,\n.navigation-bar__spacer {\n  height: 36px;\n  padding: 0 4px;\n\n  /* prevent from seeing wrapped pagedownButtons */\n  margin-bottom: 20px;\n}\n\n.navigation-bar__button {\n  width: 34px;\n  padding: 0 7px;\n  transition: opacity 0.25s;\n\n  .navigation-bar__inner--button & {\n    padding: 0 4px;\n    width: 38px;\n\n    &.navigation-bar__button--stackedit {\n      opacity: 0.85;\n\n      &:active,\n      &:focus,\n      &:hover {\n        opacity: 1;\n      }\n    }\n  }\n}\n\n.navigation-bar__button--revision {\n  width: 38px;\n\n  &:first-child {\n    margin-left: 10px;\n  }\n\n  &:last-child {\n    margin-right: 10px;\n  }\n}\n\n.navigation-bar__button--restore {\n  width: auto;\n}\n\n.navigation-bar__title {\n  margin: 0 4px;\n  font-size: 21px;\n\n  .layout--revision & {\n    position: absolute;\n    left: -9999px;\n  }\n}\n\n.navigation-bar__title,\n.navigation-bar__button {\n  display: inline-block;\n  color: $navbar-color;\n  background-color: transparent;\n}\n\n.navigation-bar__button--sync,\n.navigation-bar__button--publish {\n  padding: 0 6px;\n  margin: 0 5px;\n}\n\n.navigation-bar__button[disabled] {\n  &,\n  &:active,\n  &:focus,\n  &:hover {\n    color: $navbar-color;\n  }\n}\n\n.navigation-bar__title--input,\n.navigation-bar__button {\n  &:active,\n  &:focus,\n  &:hover {\n    color: $navbar-hover-color;\n    background-color: $navbar-hover-background;\n  }\n}\n\n.navigation-bar__button--location {\n  width: 20px;\n  height: 20px;\n  border-radius: 10px;\n  padding: 2px;\n  margin-top: 8px;\n  opacity: 0.5;\n  background-color: rgba(255, 255, 255, 0.2);\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(255, 255, 255, 0.2);\n  }\n}\n\n.navigation-bar__button--blink {\n  animation: blink 1s linear infinite;\n}\n\n.navigation-bar__title--fake {\n  position: absolute;\n  left: -9999px;\n  width: auto;\n  white-space: pre-wrap;\n}\n\n.navigation-bar__title--text {\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n\n  .navigation-bar--editor & {\n    display: none;\n  }\n}\n\n.navigation-bar__title--input,\n.navigation-bar__inner--edit-pagedownButtons {\n  display: none;\n\n  .navigation-bar--editor & {\n    display: block;\n  }\n}\n\n.navigation-bar__button {\n  display: none;\n\n  .navigation-bar__inner--button &,\n  .navigation-bar--editor & {\n    display: inline-block;\n  }\n}\n\n.navigation-bar__button--revision {\n  display: inline-block;\n}\n\n.navigation-bar__button--close {\n  color: lighten($link-color, 15%);\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: lighten($link-color, 25%);\n  }\n}\n\n.navigation-bar__title--input {\n  cursor: pointer;\n\n  &.navigation-bar__title--focus {\n    cursor: text;\n  }\n\n  .navigation-bar--light & {\n    display: none;\n  }\n}\n\n$r: 10px;\n$d: $r * 2;\n$b: $d/10;\n$t: 3000ms;\n\n.navigation-bar__spinner {\n  width: 24px;\n  margin: 7px 0 0 8px;\n\n  .icon {\n    width: 24px;\n    height: 24px;\n    color: transparentize($error-color, 0.5);\n  }\n}\n\n.spinner {\n  width: $d;\n  height: $d;\n  display: block;\n  position: relative;\n  border: $b solid transparentize($navbar-color, 0.5);\n  border-radius: 50%;\n  margin: 2px;\n\n  &::before,\n  &::after {\n    content: \"\";\n    position: absolute;\n    display: block;\n    width: $b;\n    background-color: $navbar-color;\n    border-radius: $b * 0.5;\n    transform-origin: 50% 0;\n  }\n\n  &::before {\n    height: $r * 0.4;\n    left: $r - $b * 1.5;\n    top: 50%;\n    animation: spin $t linear infinite;\n  }\n\n  &::after {\n    height: $r * 0.6;\n    left: $r - $b * 1.5;\n    top: 50%;\n    animation: spin $t/4 linear infinite;\n  }\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@keyframes blink {\n  50% {\n    opacity: 1;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/Notification.vue",
    "content": "<template>\n  <div class=\"notification\">\n    <div class=\"notification__item flex flex--row flex--align-center\" v-for=\"(item, idx) in items\" :key=\"idx\">\n      <div class=\"notification__icon flex flex--column flex--center\">\n        <icon-alert v-if=\"item.type === 'error'\"></icon-alert>\n        <icon-check-circle v-else-if=\"item.type === 'badge'\"></icon-check-circle>\n        <icon-information v-else></icon-information>\n      </div>\n      <div class=\"notification__content\">\n        {{item.content}}\n      </div>\n      <button class=\"notification__button button\" v-if=\"item.type === 'confirm'\" @click=\"item.reject\">\n        No\n      </button>\n      <button class=\"notification__button button\" v-if=\"item.type === 'confirm'\" @click=\"item.resolve\">\n        Yes\n      </button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState } from 'vuex';\n\nexport default {\n  computed: mapState('notification', [\n    'items',\n  ]),\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.notification {\n  position: absolute;\n  bottom: 0;\n  right: 0;\n  width: 100%;\n  max-width: 340px;\n}\n\n.notification__item {\n  margin: 10px;\n  padding: 10px 15px;\n  line-height: 1.4;\n  background-color: #000;\n  color: #fff;\n  font-size: 0.9em;\n  border-radius: $border-radius-base;\n}\n\n.notification__icon {\n  height: 20px;\n  width: 20px;\n  margin-right: 12px;\n  flex: none;\n}\n\n.notification__button {\n  color: $navbar-color;\n  padding: 8px;\n  flex: none;\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: $navbar-hover-color;\n    background-color: $navbar-hover-background;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/Preview.vue",
    "content": "<template>\n  <div class=\"preview\">\n    <div class=\"preview__inner-1\" @click=\"onClick\" @scroll=\"onScroll\">\n      <div class=\"preview__inner-2\" :style=\"{padding: styles.previewPadding}\">\n      </div>\n      <div class=\"gutter\" :style=\"{left: styles.previewGutterLeft + 'px'}\">\n        <comment-list v-if=\"styles.previewGutterWidth\"></comment-list>\n        <preview-new-discussion-button v-if=\"!isCurrentTemp\"></preview-new-discussion-button>\n      </div>\n    </div>\n    <div v-if=\"!styles.showEditor\" class=\"preview__corner\">\n      <button class=\"preview__button button\" @click=\"toggleEditor(true)\" v-title=\"'Edit file'\">\n        <icon-pen></icon-pen>\n      </button>\n    </div>\n  </div>\n</template>\n\n\n<script>\nimport { mapGetters, mapActions } from 'vuex';\nimport CommentList from './gutters/CommentList';\nimport PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton';\nimport store from '../store';\n\nconst appUri = `${window.location.protocol}//${window.location.host}`;\n\nexport default {\n  components: {\n    CommentList,\n    PreviewNewDiscussionButton,\n  },\n  data: () => ({\n    previewTop: true,\n  }),\n  computed: {\n    ...mapGetters('file', [\n      'isCurrentTemp',\n    ]),\n    ...mapGetters('layout', [\n      'styles',\n    ]),\n  },\n  methods: {\n    ...mapActions('data', [\n      'toggleEditor',\n    ]),\n    onClick(evt) {\n      let elt = evt.target;\n      while (elt !== this.$el) {\n        if (elt.href && elt.href.match(/^https?:\\/\\//)\n          && (!elt.hash || elt.href.slice(0, appUri.length) !== appUri)) {\n          evt.preventDefault();\n          const wnd = window.open(elt.href, '_blank');\n          wnd.focus();\n          return;\n        }\n        elt = elt.parentNode;\n      }\n    },\n    onScroll(evt) {\n      this.previewTop = evt.target.scrollTop < 10;\n    },\n  },\n  mounted() {\n    const previewElt = this.$el.querySelector('.preview__inner-2');\n    const onDiscussionEvt = cb => (evt) => {\n      let elt = evt.target;\n      while (elt && elt !== previewElt) {\n        if (elt.discussionId) {\n          cb(elt.discussionId);\n          return;\n        }\n        elt = elt.parentNode;\n      }\n    };\n\n    const classToggler = toggle => (discussionId) => {\n      previewElt.getElementsByClassName(`discussion-preview-highlighting--${discussionId}`)\n        .cl_each(elt => elt.classList.toggle('discussion-preview-highlighting--hover', toggle));\n      document.getElementsByClassName(`comment--discussion-${discussionId}`)\n        .cl_each(elt => elt.classList.toggle('comment--hover', toggle));\n    };\n\n    previewElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));\n    previewElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));\n    previewElt.addEventListener('click', onDiscussionEvt((discussionId) => {\n      store.commit('discussion/setCurrentDiscussionId', discussionId);\n    }));\n\n    this.$watch(\n      () => store.state.discussion.currentDiscussionId,\n      (discussionId, oldDiscussionId) => {\n        if (oldDiscussionId) {\n          previewElt.querySelectorAll(`.discussion-preview-highlighting--${oldDiscussionId}`)\n            .cl_each(elt => elt.classList.remove('discussion-preview-highlighting--selected'));\n        }\n        if (discussionId) {\n          previewElt.querySelectorAll(`.discussion-preview-highlighting--${discussionId}`)\n            .cl_each(elt => elt.classList.add('discussion-preview-highlighting--selected'));\n        }\n      },\n    );\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.preview,\n.preview__inner-1 {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n}\n\n.preview__inner-1 {\n  overflow: auto;\n}\n\n.preview__inner-2 {\n  margin: 0;\n}\n\n.preview__inner-2 > :first-child > :first-child {\n  margin-top: 0;\n}\n\n$corner-size: 110px;\n\n.preview__corner {\n  position: absolute;\n  top: 0;\n  right: 0;\n\n  &::before {\n    content: '';\n    position: absolute;\n    right: 0;\n    border-top: $corner-size solid rgba(0, 0, 0, 0.075);\n    border-left: $corner-size solid transparent;\n    pointer-events: none;\n\n    .app--dark & {\n      border-top-color: rgba(255, 255, 255, 0.075);\n    }\n  }\n}\n\n.preview__button {\n  position: absolute;\n  top: 15px;\n  right: 15px;\n  width: 40px;\n  height: 40px;\n  padding: 5px;\n  color: rgba(0, 0, 0, 0.25);\n\n  .app--dark & {\n    color: rgba(255, 255, 255, 0.25);\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.33);\n    background-color: transparent;\n\n    .app--dark & {\n      color: rgba(255, 255, 255, 0.33);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/SideBar.vue",
    "content": "<template>\n  <div class=\"side-bar flex flex--column\">\n    <div class=\"side-title flex flex--row\">\n      <button v-if=\"panel !== 'menu'\" class=\"side-title__button button\" @click=\"setPanel('menu')\" v-title=\"'Main menu'\">\n        <icon-dots-horizontal></icon-dots-horizontal>\n      </button>\n      <div class=\"side-title__title\">\n        {{panelName}}\n      </div>\n      <button class=\"side-title__button button\" @click=\"toggleSideBar(false)\" v-title=\"'Close side bar'\">\n        <icon-close></icon-close>\n      </button>\n    </div>\n    <div class=\"side-bar__inner\">\n      <main-menu v-if=\"panel === 'menu'\"></main-menu>\n      <workspaces-menu v-else-if=\"panel === 'workspaces'\"></workspaces-menu>\n      <sync-menu v-else-if=\"panel === 'sync'\"></sync-menu>\n      <publish-menu v-else-if=\"panel === 'publish'\"></publish-menu>\n      <history-menu v-else-if=\"panel === 'history'\"></history-menu>\n      <export-menu v-else-if=\"panel === 'export'\"></export-menu>\n      <import-export-menu v-else-if=\"panel === 'importExport'\"></import-export-menu>\n      <workspace-backup-menu v-else-if=\"panel === 'workspaceBackups'\"></workspace-backup-menu>\n      <div v-else-if=\"panel === 'help'\" class=\"side-bar__panel side-bar__panel--help\">\n        <pre class=\"markdown-highlighting\" v-html=\"markdownSample\"></pre>\n      </div>\n      <div class=\"side-bar__panel side-bar__panel--toc\" :class=\"{'side-bar__panel--hidden': panel !== 'toc'}\">\n        <toc>\n        </toc>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapActions } from 'vuex';\nimport Toc from './Toc';\nimport MainMenu from './menus/MainMenu';\nimport WorkspacesMenu from './menus/WorkspacesMenu';\nimport SyncMenu from './menus/SyncMenu';\nimport PublishMenu from './menus/PublishMenu';\nimport HistoryMenu from './menus/HistoryMenu';\nimport ImportExportMenu from './menus/ImportExportMenu';\nimport WorkspaceBackupMenu from './menus/WorkspaceBackupMenu';\nimport markdownSample from '../data/markdownSample.md';\nimport markdownConversionSvc from '../services/markdownConversionSvc';\nimport store from '../store';\n\nconst panelNames = {\n  menu: 'Menu',\n  workspaces: 'Workspaces',\n  help: 'Markdown cheat sheet',\n  toc: 'Table of contents',\n  sync: 'Synchronize',\n  publish: 'Publish',\n  history: 'File history',\n  importExport: 'Import/export',\n  workspaceBackups: 'Workspace backups',\n};\n\nexport default {\n  components: {\n    Toc,\n    MainMenu,\n    WorkspacesMenu,\n    SyncMenu,\n    PublishMenu,\n    HistoryMenu,\n    ImportExportMenu,\n    WorkspaceBackupMenu,\n  },\n  data: () => ({\n    markdownSample: markdownConversionSvc.highlight(markdownSample),\n  }),\n  computed: {\n    panel() {\n      if (store.state.light) {\n        return null; // No menu in light mode\n      }\n      const result = store.getters['data/layoutSettings'].sideBarPanel;\n      return panelNames[result] ? result : 'menu';\n    },\n    panelName() {\n      return panelNames[this.panel];\n    },\n  },\n  methods: {\n    ...mapActions('data', [\n      'toggleSideBar',\n    ]),\n    ...mapActions('data', {\n      setPanel: 'setSideBarPanel',\n    }),\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.side-bar {\n  overflow: hidden;\n  height: 100%;\n\n  hr {\n    margin: 10px 40px;\n    display: none;\n    border-top: 1px solid $hr-color;\n  }\n\n  * + hr {\n    display: block;\n  }\n\n  hr + hr {\n    display: none;\n  }\n\n  .textfield {\n    font-size: 14px;\n    height: 26px;\n  }\n}\n\n.side-bar__inner {\n  position: relative;\n  height: 100%;\n}\n\n.side-bar__panel {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  overflow: auto;\n\n  &::after {\n    content: '';\n    display: block;\n    height: 40px;\n  }\n}\n\n.side-bar__panel--hidden {\n  left: 1000px;\n}\n\n.side-bar__panel--menu {\n  padding: 10px;\n}\n\n.side-bar__panel--help {\n  padding: 0 10px 0 20px;\n\n  pre {\n    font-size: 0.9em;\n    font-variant-ligatures: no-common-ligatures;\n    line-height: 1.25;\n    white-space: pre-wrap;\n    word-break: break-word;\n    word-wrap: break-word;\n  }\n\n  .code,\n  .img,\n  .imgref,\n  .cl-toc {\n    background-color: rgba(0, 0, 0, 0.05);\n  }\n}\n\n.side-bar__info {\n  padding: 10px;\n  margin: -10px -10px 10px;\n  background-color: $info-bg;\n  font-size: 0.95em;\n\n  p {\n    margin: 10px 15px;\n    font-size: 0.9rem;\n    opacity: 0.67;\n    line-height: 1.3;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/SplashScreen.vue",
    "content": "<template>\n  <div class=\"splash-screen\">\n    <div class=\"splash-screen__inner logo-background\"></div>\n  </div>\n</template>\n\n<style lang=\"scss\">\n.splash-screen {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  padding: 25px;\n}\n\n.splash-screen__inner {\n  margin: 0 auto;\n  max-width: 600px;\n  height: 100%;\n}\n</style>\n"
  },
  {
    "path": "src/components/StatusBar.vue",
    "content": "<template>\n  <div class=\"stat-panel panel no-overflow\">\n    <div class=\"stat-panel__block stat-panel__block--left\" v-if=\"styles.showEditor\">\n      <span class=\"stat-panel__block-name\">\n        Markdown\n        <span v-if=\"textSelection\">selection</span>\n      </span>\n      <span v-for=\"stat in textStats\" :key=\"stat.id\">\n        <span class=\"stat-panel__value\">{{stat.value}}</span> {{stat.name}}\n      </span>\n      <span class=\"stat-panel__value\">Ln {{line}}, Col {{column}}</span>\n    </div>\n    <div class=\"stat-panel__block stat-panel__block--right\">\n      <span class=\"stat-panel__block-name\">\n        HTML\n        <span v-if=\"htmlSelection\">selection</span>\n      </span>\n      <span v-for=\"stat in htmlStats\" :key=\"stat.id\">\n        <span class=\"stat-panel__value\">{{stat.value}}</span> {{stat.name}}\n      </span>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport editorSvc from '../services/editorSvc';\nimport utils from '../services/utils';\n\nclass Stat {\n  constructor(name, regex) {\n    this.id = utils.uid();\n    this.name = name;\n    this.regex = new RegExp(regex, 'gm');\n    this.value = null;\n  }\n}\n\nexport default {\n  data: () => ({\n    textSelection: false,\n    htmlSelection: false,\n    line: 0,\n    column: 0,\n    textStats: [\n      new Stat('bytes', '[\\\\s\\\\S]'),\n      new Stat('words', '\\\\S+'),\n      new Stat('lines', '\\n'),\n    ],\n    htmlStats: [\n      new Stat('characters', '\\\\S'),\n      new Stat('words', '\\\\S+'),\n      new Stat('paragraphs', '\\\\S.*'),\n    ],\n  }),\n  computed: mapGetters('layout', [\n    'styles',\n  ]),\n  created() {\n    editorSvc.$on('sectionList', () => this.computeText());\n    editorSvc.$on('selectionRange', () => this.computeText());\n    editorSvc.$on('previewCtx', () => this.computeHtml());\n    editorSvc.$on('previewSelectionRange', () => this.computeHtml());\n  },\n\n  methods: {\n    computeText() {\n      setTimeout(() => {\n        this.textSelection = false;\n        let text = editorSvc.clEditor.getContent();\n        const beforeText = text.slice(0, editorSvc.clEditor.selectionMgr.selectionEnd);\n        const beforeLines = beforeText.split('\\n');\n        this.line = beforeLines.length;\n        this.column = beforeLines.pop().length;\n\n        const selectedText = editorSvc.clEditor.selectionMgr.getSelectedText();\n        if (selectedText) {\n          this.textSelection = true;\n          text = selectedText;\n        }\n        this.textStats.forEach((stat) => {\n          stat.value = (text.match(stat.regex) || []).length;\n        });\n      }, 10);\n    },\n    computeHtml() {\n      setTimeout(() => {\n        let text;\n        if (editorSvc.previewSelectionRange) {\n          text = `${editorSvc.previewSelectionRange}`;\n        }\n        this.htmlSelection = true;\n        if (!text) {\n          this.htmlSelection = false;\n          ({ text } = editorSvc.previewCtx);\n        }\n        if (text != null) {\n          this.htmlStats.forEach((stat) => {\n            stat.value = (text.match(stat.regex) || []).length;\n          });\n        }\n      }, 10);\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.stat-panel {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  color: #fff;\n  font-size: 12px;\n}\n\n.stat-panel__block {\n  margin: 0 10px;\n}\n\n.stat-panel__block--left {\n  float: left;\n}\n\n.stat-panel__block--right {\n  float: right;\n}\n\n.stat-panel__value {\n  font-weight: 600;\n  margin-left: 5px;\n}\n</style>\n"
  },
  {
    "path": "src/components/Toc.vue",
    "content": "<template>\n  <div class=\"toc\">\n    <div class=\"toc__mask\" :style=\"{top: (maskY - 5) + 'px'}\"></div>\n    <div class=\"toc__inner\"></div>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport editorSvc from '../services/editorSvc';\n\nexport default {\n  data: () => ({\n    maskY: 0,\n  }),\n  computed: {\n    ...mapGetters('layout', [\n      'styles',\n    ]),\n  },\n  mounted() {\n    const tocElt = this.$el.querySelector('.toc__inner');\n\n    // TOC click behaviour\n    let isMousedown;\n    function onClick(e) {\n      if (!isMousedown) {\n        return;\n      }\n      e.preventDefault();\n      const y = e.clientY - tocElt.getBoundingClientRect().top;\n\n      editorSvc.previewCtx.sectionDescList.some((sectionDesc) => {\n        if (y >= sectionDesc.tocDimension.endOffset) {\n          return false;\n        }\n        const posInSection = (y - sectionDesc.tocDimension.startOffset)\n          / (sectionDesc.tocDimension.height || 1);\n        const editorScrollTop = sectionDesc.editorDimension.startOffset\n          + (sectionDesc.editorDimension.height * posInSection);\n        editorSvc.editorElt.parentNode.scrollTop = editorScrollTop;\n        const previewScrollTop = sectionDesc.previewDimension.startOffset\n          + (sectionDesc.previewDimension.height * posInSection);\n        editorSvc.previewElt.parentNode.scrollTop = previewScrollTop;\n        return true;\n      });\n    }\n\n    tocElt.addEventListener('mouseup', () => {\n      isMousedown = false;\n    });\n    tocElt.addEventListener('mouseleave', () => {\n      isMousedown = false;\n    });\n    tocElt.addEventListener('mousedown', (e) => {\n      isMousedown = e.which === 1;\n      onClick(e);\n    });\n    tocElt.addEventListener('mousemove', (e) => {\n      onClick(e);\n    });\n\n    // Change mask postion on scroll\n    const updateMaskY = () => {\n      const scrollPosition = editorSvc.getScrollPosition();\n      if (scrollPosition) {\n        const sectionDesc = editorSvc.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx];\n        this.maskY = sectionDesc.tocDimension.startOffset +\n          (scrollPosition.posInSection * sectionDesc.tocDimension.height);\n      }\n    };\n\n    this.$nextTick(() => {\n      editorSvc.editorElt.parentNode.addEventListener('scroll', () => {\n        if (this.styles.showEditor) {\n          updateMaskY();\n        }\n      });\n      editorSvc.previewElt.parentNode.addEventListener('scroll', () => {\n        if (!this.styles.showEditor) {\n          updateMaskY();\n        }\n      });\n    });\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.toc__inner {\n  position: relative;\n  color: rgba(0, 0, 0, 0.67);\n  cursor: pointer;\n  font-size: 9px;\n  padding: 10px 20px 40px;\n  white-space: nowrap;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n\n  * {\n    font-weight: inherit;\n    pointer-events: none;\n  }\n\n  .cl-toc-section {\n    h1,\n    h2 {\n      &::after {\n        display: none;\n      }\n    }\n\n    h1 {\n      margin: 1rem 0;\n    }\n\n    h2 {\n      margin: 0.5rem 0;\n      margin-left: 8px;\n    }\n\n    h3 {\n      margin: 0.33rem 0;\n      margin-left: 16px;\n    }\n\n    h4 {\n      margin: 0.22rem 0;\n      margin-left: 24px;\n    }\n\n    h5 {\n      margin: 0.11rem 0;\n      margin-left: 32px;\n    }\n\n    h6 {\n      margin: 0;\n      margin-left: 40px;\n    }\n  }\n}\n\n.toc__mask {\n  position: absolute;\n  left: 0;\n  width: 100%;\n  height: 35px;\n  background-color: rgba(255, 255, 255, 0.2);\n  pointer-events: none;\n}\n</style>\n"
  },
  {
    "path": "src/components/Tour.vue",
    "content": "<template>\n  <div class=\"tour\" @keydown.esc.stop=\"skip\">\n    <div class=\"tour-step\" :class=\"'tour-step--' + step\" :style=\"stepStyle\">\n      <div class=\"tour-step__inner\" v-if=\"step === 'welcome'\">\n        <h2>Welcome back!</h2>\n        <p>The new <b>StackEdit 5</b> is here!</p>\n        <p>Please click <b>Next</b> to take a quick tour.</p>\n        <div class=\"tour-step__button-bar\">\n          <button class=\"button\" @click=\"finish\">Skip</button>\n          <button class=\"button button--resolve\" @click=\"next\">Next</button>\n        </div>\n      </div>\n      <div class=\"tour-step__inner\" v-else-if=\"step === 'editor'\">\n        <h2>Your Markdown editor</h2>\n        <p>StackEdit converts your Markdown to HTML in real-time.</p>\n        <p>Click <icon-side-preview></icon-side-preview> to toggle the side preview.</p>\n        <div class=\"tour-step__button-bar\">\n          <button class=\"button\" @click=\"finish\">Skip</button>\n          <button class=\"button button--resolve\" @click=\"next\">Next</button>\n        </div>\n      </div>\n      <div class=\"tour-step__inner\" v-else-if=\"step === 'explorer'\">\n        <h2>File explorer</h2>\n        <p>StackEdit can manage multiple files and folders in a workspace.</p>\n        <p>Click <icon-folder></icon-folder> to open the file explorer.</p>\n        <div class=\"tour-step__button-bar\">\n          <button class=\"button\" @click=\"finish\">Skip</button>\n          <button class=\"button button--resolve\" @click=\"next\">Next</button>\n        </div>\n      </div>\n      <div class=\"tour-step__inner\" v-else-if=\"step === 'menu'\">\n        <h2>Do a lot more!</h2>\n        <p>StackEdit can also synchronize and publish your files, manage collaborative workspaces...</p>\n        <p>Click <icon-provider provider-id=\"stackedit\"></icon-provider> to explore the menu.</p>\n        <div class=\"tour-step__button-bar\">\n          <button class=\"button\" @click=\"finish\">Skip</button>\n          <button class=\"button button--resolve\" @click=\"next\">Next</button>\n        </div>\n      </div>\n      <div class=\"tour-step__inner\" v-else-if=\"step === 'end'\">\n        <h2>Enjoy!</h2>\n        <p>If you like StackEdit, please rate 5 stars on the <a target=\"_blank\" href=\"https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg/reviews\">Chrome Web Store</a>.</p>\n        <p>You can also star the project on <a target=\"_blank\" href=\"https://github.com/benweet/stackedit\">GitHub</a> and join the <a target=\"_blank\" href=\"https://community.stackedit.io/\">community</a>.</p>\n        <div class=\"tour-step__button-bar\">\n          <button class=\"button button--resolve\" @click=\"finish\">Ok</button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport Vue from 'vue';\nimport store from '../store';\n\nconst steps = [\n  'welcome',\n  'editor',\n  'explorer',\n  'menu',\n  'end',\n];\n\nexport default {\n  data: () => ({\n    stepIdx: 0,\n    stepStyles: {},\n  }),\n  computed: {\n    step() {\n      return steps[this.stepIdx];\n    },\n    stepStyle() {\n      return this.stepStyles[this.step] || {};\n    },\n  },\n  methods: {\n    updatePositions() {\n      document.querySelectorAll('[tour-step-anchor]').cl_each((anchorElt) => {\n        const anchorRect = anchorElt.getBoundingClientRect();\n        const anchorSteps = (anchorElt.getAttribute('tour-step-anchor') || '').split(',');\n        anchorSteps.forEach((step) => {\n          const style = {\n            top: `${anchorRect.top + (anchorRect.height / 2)}px`,\n            left: `${anchorRect.left + (anchorRect.width / 2)}px`,\n          };\n          switch (step) {\n            case 'welcome':\n            case 'end': {\n              style.top = `${anchorRect.top}px`;\n              break;\n            }\n            case 'editor':\n            case 'menu': {\n              style.left = `${anchorRect.left}px`;\n              break;\n            }\n            case 'explorer': {\n              style.left = `${anchorRect.left + anchorRect.width}px`;\n              break;\n            }\n            default:\n              return;\n          }\n          Vue.set(this.stepStyles, step, style);\n        });\n      });\n    },\n    finish() {\n      store.dispatch('data/patchLayoutSettings', {\n        welcomeTourFinished: true,\n      });\n    },\n    next() {\n      this.stepIdx += 1;\n    },\n  },\n  mounted() {\n    this.$watch(\n      () => store.getters['layout/styles'],\n      () => this.updatePositions(),\n      { immediate: true },\n    );\n  },\n};\n</script>\n\n\n<style lang=\"scss\">\n@import '../styles/variables.scss';\n\n.tour {\n  position: absolute;\n  top: 0;\n  left: 0;\n}\n\n.tour-step {\n  position: absolute;\n}\n\n$tour-step-background: transparentize(mix(#f3f3f3, $selection-highlighting-color, 75%), 0.025);\n$tour-step-width: 240px;\n\n.tour-step__inner {\n  position: absolute;\n  background-color: $tour-step-background;\n  padding: 1.5em;\n  font-size: 0.9em;\n  line-height: 1.33;\n  width: $tour-step-width;\n  text-align: center;\n  border-radius: $border-radius-base;\n\n  h2 {\n    margin: 0;\n\n    &::after {\n      display: none;\n    }\n  }\n\n  .icon,\n  .icon-provider {\n    width: 1.25em;\n    height: 1.25em;\n    vertical-align: bottom;\n    display: inline-block;\n  }\n\n  &::before {\n    content: '';\n    position: absolute;\n  }\n\n  .tour-step--welcome &,\n  .tour-step--end & {\n    left: -$tour-step-width/2;\n    top: 36px;\n    border-bottom-right-radius: 0;\n\n    &::before {\n      bottom: -10px;\n      right: 0;\n      border-top: 10px solid $tour-step-background;\n      border-left: 10px solid transparent;\n    }\n  }\n\n  .tour-step--editor &,\n  .tour-step--menu & {\n    right: 15px;\n    border-top-right-radius: 0;\n\n    &::before {\n      top: 0;\n      right: -10px;\n      border-top: 10px solid $tour-step-background;\n      border-right: 10px solid transparent;\n    }\n  }\n\n  .tour-step--explorer & {\n    left: 15px;\n    border-top-left-radius: 0;\n\n    &::before {\n      top: 0;\n      left: -10px;\n      border-top: 10px solid $tour-step-background;\n      border-left: 10px solid transparent;\n    }\n  }\n}\n\n.tour-step__button-bar {\n  margin-top: 1.5em;\n  display: flex;\n  flex-direction: row;\n  justify-content: flex-end;\n\n  .button {\n    font-size: 1.1em;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/UserImage.vue",
    "content": "<template>\n  <div class=\"user-image\" :style=\"{backgroundImage: url}\">\n  </div>\n</template>\n\n<script>\nimport userSvc from '../services/userSvc';\nimport store from '../store';\n\nexport default {\n  props: ['userId'],\n  computed: {\n    sanitizedUserId() {\n      return userSvc.sanitizeUserId(this.userId);\n    },\n    url() {\n      const userInfo = store.state.userInfo.itemsById[this.sanitizedUserId];\n      return userInfo && userInfo.imageUrl && `url('${userInfo.imageUrl}')`;\n    },\n  },\n  watch: {\n    sanitizedUserId: {\n      handler: sanitizedUserId => userSvc.addUserId(sanitizedUserId),\n      immediate: true,\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.user-image {\n  width: 100%;\n  height: 100%;\n  background-color: #fff;\n  background-repeat: no-repeat;\n  background-position: center;\n  background-size: contain;\n}\n</style>\n"
  },
  {
    "path": "src/components/UserName.vue",
    "content": "<template>\n  <span class=\"user-name\">{{name}}</span>\n</template>\n\n<script>\nimport userSvc from '../services/userSvc';\nimport store from '../store';\n\nexport default {\n  props: ['userId'],\n  computed: {\n    sanitizedUserId() {\n      return userSvc.sanitizeUserId(this.userId);\n    },\n    name() {\n      const userInfo = store.state.userInfo.itemsById[this.sanitizedUserId];\n      return userInfo ? userInfo.name : 'Someone';\n    },\n  },\n  watch: {\n    sanitizedUserId: {\n      handler: sanitizedUserId => userSvc.addUserId(sanitizedUserId),\n      immediate: true,\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/common/EditorClassApplier.js",
    "content": "import cledit from '../../services/editor/cledit';\nimport editorSvc from '../../services/editorSvc';\nimport utils from '../../services/utils';\n\nlet savedSelection = null;\nconst nextTickCbs = [];\nconst nextTickExecCbs = cledit.Utils.debounce(() => {\n  while (nextTickCbs.length) {\n    nextTickCbs.shift()();\n  }\n  if (savedSelection) {\n    editorSvc.clEditor.selectionMgr.setSelectionStartEnd(\n      savedSelection.start,\n      savedSelection.end,\n    );\n  }\n  savedSelection = null;\n});\n\nconst nextTick = (cb) => {\n  nextTickCbs.push(cb);\n  nextTickExecCbs();\n};\n\nconst nextTickRestoreSelection = () => {\n  savedSelection = {\n    start: editorSvc.clEditor.selectionMgr.selectionStart,\n    end: editorSvc.clEditor.selectionMgr.selectionEnd,\n  };\n  nextTickExecCbs();\n};\n\nexport default class EditorClassApplier {\n  constructor(classGetter, offsetGetter, properties) {\n    this.classGetter = typeof classGetter === 'function' ? classGetter : () => classGetter;\n    this.offsetGetter = typeof offsetGetter === 'function' ? offsetGetter : () => offsetGetter;\n    this.properties = properties || {};\n    this.eltCollection = editorSvc.editorElt.getElementsByClassName(this.classGetter()[0]);\n    this.lastEltCount = this.eltCollection.length;\n\n    this.restoreClass = () => {\n      if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {\n        this.removeClass();\n        this.applyClass();\n      }\n    };\n\n    editorSvc.clEditor.on('contentChanged', this.restoreClass);\n    nextTick(() => this.restoreClass());\n  }\n\n  applyClass() {\n    if (!this.stopped) {\n      const offset = this.offsetGetter();\n      if (offset && offset.start !== offset.end) {\n        const range = editorSvc.clEditor.selectionMgr.createRange(\n          Math.min(offset.start, offset.end),\n          Math.max(offset.start, offset.end),\n        );\n        const properties = {\n          ...this.properties,\n          className: this.classGetter().join(' '),\n        };\n        editorSvc.clEditor.watcher.noWatch(() => {\n          utils.wrapRange(range, properties);\n        });\n        if (editorSvc.clEditor.selectionMgr.hasFocus()) {\n          nextTickRestoreSelection();\n        }\n        this.lastEltCount = this.eltCollection.length;\n      }\n    }\n  }\n\n  removeClass() {\n    editorSvc.clEditor.watcher.noWatch(() => {\n      utils.unwrapRange(this.eltCollection);\n    });\n    if (editorSvc.clEditor.selectionMgr.hasFocus()) {\n      nextTickRestoreSelection();\n    }\n  }\n\n  stop() {\n    editorSvc.clEditor.off('contentChanged', this.restoreClass);\n    nextTick(() => this.removeClass());\n    this.stopped = true;\n  }\n}\n"
  },
  {
    "path": "src/components/common/PreviewClassApplier.js",
    "content": "import cledit from '../../services/editor/cledit';\nimport editorSvc from '../../services/editorSvc';\nimport utils from '../../services/utils';\n\nconst nextTickCbs = [];\nconst nextTickExecCbs = cledit.Utils.debounce(() => {\n  while (nextTickCbs.length) {\n    nextTickCbs.shift()();\n  }\n});\n\nconst nextTick = (cb) => {\n  nextTickCbs.push(cb);\n  nextTickExecCbs();\n};\n\nexport default class PreviewClassApplier {\n  constructor(classGetter, offsetGetter, properties) {\n    this.classGetter = typeof classGetter === 'function' ? classGetter : () => classGetter;\n    this.offsetGetter = typeof offsetGetter === 'function' ? offsetGetter : () => offsetGetter;\n    this.properties = properties || {};\n    this.eltCollection = editorSvc.previewElt.getElementsByClassName(this.classGetter()[0]);\n    this.lastEltCount = this.eltCollection.length;\n\n    this.restoreClass = () => {\n      if (!editorSvc.previewCtxWithDiffs) {\n        this.removeClass();\n      } else if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {\n        this.removeClass();\n        this.applyClass();\n      }\n    };\n\n    editorSvc.$on('previewCtxWithDiffs', this.restoreClass);\n    nextTick(() => this.restoreClass());\n  }\n\n  applyClass() {\n    if (!this.stopped) {\n      const offset = this.offsetGetter();\n      if (offset) {\n        const offsetStart = editorSvc.getPreviewOffset(\n          offset.start,\n          editorSvc.previewCtx.sectionDescList,\n        );\n        const offsetEnd = editorSvc.getPreviewOffset(\n          offset.end,\n          editorSvc.previewCtx.sectionDescList,\n        );\n        if (offsetStart != null && offsetEnd != null && offsetStart !== offsetEnd) {\n          const start = cledit.Utils.findContainer(\n            editorSvc.previewElt,\n            Math.min(offsetStart, offsetEnd),\n          );\n          const end = cledit.Utils.findContainer(\n            editorSvc.previewElt,\n            Math.max(offsetStart, offsetEnd),\n          );\n          const range = document.createRange();\n          range.setStart(start.container, start.offsetInContainer);\n          range.setEnd(end.container, end.offsetInContainer);\n          const properties = {\n            ...this.properties,\n            className: this.classGetter().join(' '),\n          };\n          utils.wrapRange(range, properties);\n          this.lastEltCount = this.eltCollection.length;\n        }\n      }\n    }\n  }\n\n  removeClass() {\n    utils.unwrapRange(this.eltCollection);\n  }\n\n  stop() {\n    editorSvc.$off('previewCtxWithDiffs', this.restoreClass);\n    nextTick(() => this.removeClass());\n    this.stopped = true;\n  }\n}\n"
  },
  {
    "path": "src/components/common/vueGlobals.js",
    "content": "import Vue from 'vue';\nimport Clipboard from 'clipboard';\nimport timeSvc from '../../services/timeSvc';\nimport store from '../../store';\n\n// Global directives\nVue.directive('focus', {\n  inserted(el) {\n    el.focus();\n    const { value } = el;\n    if (value && el.setSelectionRange) {\n      el.setSelectionRange(0, value.length);\n    }\n  },\n});\n\nconst setVisible = (el, value) => {\n  el.style.display = value ? '' : 'none';\n  if (value) {\n    el.removeAttribute('aria-hidden');\n  } else {\n    el.setAttribute('aria-hidden', 'true');\n  }\n};\nVue.directive('show', {\n  bind(el, { value }) {\n    setVisible(el, value);\n  },\n  update(el, { value, oldValue }) {\n    if (value !== oldValue) {\n      setVisible(el, value);\n    }\n  },\n});\n\nconst setElTitle = (el, title) => {\n  el.title = title;\n  el.setAttribute('aria-label', title);\n};\nVue.directive('title', {\n  bind(el, { value }) {\n    setElTitle(el, value);\n  },\n  update(el, { value, oldValue }) {\n    if (value !== oldValue) {\n      setElTitle(el, value);\n    }\n  },\n});\n\n// Clipboard directive\nconst createClipboard = (el, value) => {\n  el.seClipboard = new Clipboard(el, { text: () => value });\n};\nconst destroyClipboard = (el) => {\n  if (el.seClipboard) {\n    el.seClipboard.destroy();\n    el.seClipboard = null;\n  }\n};\nVue.directive('clipboard', {\n  bind(el, { value }) {\n    createClipboard(el, value);\n  },\n  update(el, { value, oldValue }) {\n    if (value !== oldValue) {\n      destroyClipboard(el);\n      createClipboard(el, value);\n    }\n  },\n  unbind(el) {\n    destroyClipboard(el);\n  },\n});\n\n// Global filters\nVue.filter('formatTime', time =>\n  // Access the time counter for reactive refresh\n  timeSvc.format(time, store.state.timeCounter));\n\n"
  },
  {
    "path": "src/components/gutters/Comment.vue",
    "content": "<template>\n  <div class=\"comment\">\n    <div class=\"comment__header flex flex--row flex--space-between flex--align-center\">\n      <div class=\"comment__user flex flex--row flex--align-center\">\n        <div class=\"comment__user-image\">\n          <user-image :user-id=\"comment.sub\"></user-image>\n        </div>\n        <button class=\"comment__remove-button button\" v-title=\"'Remove comment'\" @click=\"removeComment\">\n          <icon-delete></icon-delete>\n        </button>\n        <user-name :user-id=\"comment.sub\"></user-name>\n      </div>\n      <div class=\"comment__created\">{{comment.created | formatTime}}</div>\n    </div>\n    <div class=\"comment__text\">\n      <div class=\"comment__text-inner\" v-html=\"text\"></div>\n    </div>\n    <div class=\"comment__buttons flex flex--row flex--end\" v-if=\"showReply\">\n      <button class=\"comment__button button\" @click=\"setIsCommenting(true)\">Reply</button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapMutations } from 'vuex';\nimport UserImage from '../UserImage';\nimport UserName from '../UserName';\nimport editorSvc from '../../services/editorSvc';\nimport htmlSanitizer from '../../libs/htmlSanitizer';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default {\n  components: {\n    UserImage,\n    UserName,\n  },\n  props: ['comment'],\n  computed: {\n    showReply() {\n      return this.comment === store.getters['discussion/currentDiscussionLastComment'] &&\n        !store.state.discussion.isCommenting;\n    },\n    text() {\n      return htmlSanitizer.sanitizeHtml(editorSvc.converter.render(this.comment.text));\n    },\n  },\n  methods: {\n    ...mapMutations('discussion', [\n      'setIsCommenting',\n    ]),\n    async removeComment() {\n      try {\n        await store.dispatch('modal/open', 'commentDeletion');\n        store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment });\n        badgeSvc.addBadge('removeComment');\n      } catch (e) {\n        // Cancel\n      }\n    },\n  },\n  mounted() {\n    const isSticky = this.$el.parentNode.classList.contains('sticky-comment');\n    if (isSticky) {\n      const commentId = store.getters['discussion/currentDiscussionLastCommentId'];\n      const scrollerElt = this.$el.querySelector('.comment__text-inner');\n\n      let scrollerMirrorElt;\n      const getScrollerMirrorElt = () => {\n        if (!scrollerMirrorElt) {\n          scrollerMirrorElt = document.querySelector(`.comment-list .comment--${commentId} .comment__text-inner`);\n        }\n        return scrollerMirrorElt || { scrollTop: 0 };\n      };\n\n      scrollerElt.scrollTop = getScrollerMirrorElt().scrollTop;\n      scrollerElt.addEventListener('scroll', () => {\n        getScrollerMirrorElt().scrollTop = scrollerElt.scrollTop;\n      });\n    }\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/gutters/CommentList.vue",
    "content": "<template>\n  <div class=\"comment-list\" :class=\"stickyComment && 'comment-list--' + stickyComment\" :style=\"{width: constants.gutterWidth + 'px'}\">\n    <comment v-for=\"(comment, discussionId) in currentFileDiscussionLastComments\" :key=\"discussionId\" v-if=\"comment.discussionId !== currentDiscussionId\" :comment=\"comment\" class=\"comment--last\" :class=\"'comment--discussion-' + discussionId\" :style=\"{top: tops[discussionId] + 'px'}\" @click.native=\"setCurrentDiscussionId(discussionId)\"></comment>\n    <div class=\"comment-list__current-discussion\" :style=\"{top: tops.current + 'px'}\">\n      <comment v-for=\"(comment, id) in currentDiscussionComments\" :key=\"id\" :comment=\"comment\" :class=\"'comment--' + id\"></comment>\n      <new-comment v-if=\"isCommenting\"></new-comment>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters, mapMutations } from 'vuex';\nimport Comment from './Comment';\nimport NewComment from './NewComment';\nimport editorSvc from '../../services/editorSvc';\nimport store from '../../store';\nimport utils from '../../services/utils';\n\nexport default {\n  components: {\n    Comment,\n    NewComment,\n  },\n  data: () => ({\n    tops: {},\n  }),\n  computed: {\n    ...mapGetters('layout', [\n      'constants',\n      'styles',\n    ]),\n    ...mapState('discussion', [\n      'currentDiscussionId',\n      'isCommenting',\n      'newCommentText',\n      'stickyComment',\n    ]),\n    ...mapGetters('discussion', [\n      'newDiscussion',\n      'currentDiscussion',\n      'currentFileDiscussions',\n      'currentFileDiscussionLastComments',\n      'currentDiscussionComments',\n      'currentDiscussionLastCommentId',\n    ]),\n    updateTopsTrigger() {\n      return utils.serializeObject([\n        this.styles,\n        this.currentFileDiscussionLastComments,\n        this.currentDiscussionComments,\n        this.currentDiscussionId,\n        this.isCommenting,\n      ]);\n    },\n    updateStickyTrigger() {\n      return utils.serializeObject([\n        this.updateTopsTrigger,\n        this.newCommentText,\n      ]);\n    },\n  },\n  methods: {\n    ...mapMutations('discussion', [\n      'setCurrentDiscussionId',\n    ]),\n    updateTops() {\n      const layoutSettings = store.getters['data/layoutSettings'];\n      const minTop = -2;\n      let minCommentTop = minTop;\n      const getTop = (discussion, commentElt1, commentElt2, isCurrent) => {\n        const firstElt = commentElt1 || commentElt2;\n        const secondElt = commentElt1 && commentElt2;\n        const coordinates = layoutSettings.showEditor\n          ? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)\n          : editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));\n        let commentTop = minTop;\n        if (coordinates) {\n          commentTop = (coordinates.top + coordinates.height) - 80;\n        }\n        let top = commentTop;\n        if (isCurrent) {\n          top -= firstElt.offsetTop + 2; // 2 for top border\n        }\n        if (top < minTop) {\n          commentTop += minTop - top;\n          top = minTop;\n        }\n        if (commentTop < minCommentTop) {\n          top += minCommentTop - commentTop;\n          commentTop = minCommentTop;\n        }\n        minCommentTop = commentTop + firstElt.offsetHeight + 60;\n        if (secondElt) {\n          minCommentTop += secondElt.offsetHeight;\n        }\n        return top;\n      };\n\n      // Get the discussion top coordinates\n      const tops = {};\n      const discussions = this.currentFileDiscussions;\n      Object.entries(discussions)\n        .sort(([, discussion1], [, discussion2]) => discussion1.end - discussion2.end)\n        .forEach(([discussionId, discussion]) => {\n          if (discussion === this.currentDiscussion || discussion === this.newDiscussion) {\n            tops.current = getTop(\n              discussion,\n              this.currentDiscussionLastCommentId\n                && this.$el.querySelector(`.comment--${this.currentDiscussionLastCommentId}`),\n              this.$el.querySelector('.comment--new'),\n              true,\n            );\n          } else {\n            tops[discussionId] = getTop(\n              discussion,\n              this.$el.querySelector(`.comment--discussion-${discussionId}`),\n            );\n          }\n        });\n      this.tops = tops;\n    },\n  },\n  mounted() {\n    this.$watch(\n      () => this.updateTopsTrigger,\n      () => this.updateTops(),\n      { immediate: true },\n    );\n\n    const layoutSettings = store.getters['data/layoutSettings'];\n    this.scrollerElt = layoutSettings.showEditor\n      ? editorSvc.editorElt.parentNode\n      : editorSvc.previewElt.parentNode;\n\n    this.updateSticky = () => {\n      let height = 0;\n      let offsetTop = this.tops.current;\n      const lastCommentElt = this.$el.querySelector(`.comment--${this.currentDiscussionLastCommentId}`);\n      if (lastCommentElt) {\n        height += lastCommentElt.clientHeight;\n        offsetTop += lastCommentElt.offsetTop;\n      }\n      const newCommentElt = this.$el.querySelector('.comment--new');\n      if (newCommentElt) {\n        height += newCommentElt.clientHeight;\n      }\n      const currentDiscussionElt = document.querySelector('.current-discussion__inner');\n      const minOffsetTop = this.scrollerElt.scrollTop + 10;\n      const maxOffsetTop = (this.scrollerElt.scrollTop + this.scrollerElt.clientHeight) - height\n        - currentDiscussionElt.clientHeight;\n      let stickyComment = null;\n      if (offsetTop > maxOffsetTop || maxOffsetTop < minOffsetTop) {\n        stickyComment = 'bottom';\n      } else if (offsetTop < minOffsetTop) {\n        stickyComment = 'top';\n      }\n      if (store.state.discussion.stickyComment !== stickyComment) {\n        store.commit('discussion/setStickyComment', stickyComment);\n      }\n    };\n\n    this.scrollerElt.addEventListener('scroll', this.updateSticky);\n    this.$watch(\n      () => this.updateStickyTrigger,\n      () => this.updateSticky(),\n      { immediate: true },\n    );\n\n    // Move preview discussions once previewCtxWithDiffs has been calculated\n    if (!editorSvc.previewCtxWithDiffs) {\n      editorSvc.$once('previewCtxWithDiffs', () => {\n        this.updateTops();\n        this.updateSticky();\n      });\n    }\n  },\n  destroyed() {\n    this.scrollerElt.removeEventListener('scroll', this.updateSticky);\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.comment-list {\n  position: absolute;\n  right: 0;\n  font-size: 15px;\n}\n\n.comment--last,\n.comment-list__current-discussion {\n  position: absolute;\n  width: 100%;\n  padding-top: 10px;\n}\n\n/* use div selector to avoid collision with Prism */\ndiv.comment {\n  padding: 5px 10px 10px;\n}\n\n.comment--last {\n  opacity: 0.33;\n  cursor: pointer;\n\n  * {\n    pointer-events: none;\n  }\n\n  &:hover,\n  &.comment--hover {\n    opacity: 0.5;\n  }\n}\n\n.comment__header {\n  font-size: 0.75em;\n  padding-bottom: 0.25em;\n}\n\n.comment__user-image {\n  height: 20px;\n  width: 20px;\n  border-radius: $border-radius-base;\n  overflow: hidden;\n  margin-right: 5px;\n\n  .comment:hover & {\n    display: none;\n\n    .sticky-comment & {\n      display: block;\n    }\n  }\n\n  .comment--new:hover &,\n  .comment--last:hover & {\n    display: block;\n  }\n}\n\n.comment__remove-button {\n  height: 20px;\n  width: 20px;\n  padding: 1px;\n  color: rgba(0, 0, 0, 0.33);\n  margin-right: 5px;\n  display: none;\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.5);\n  }\n\n  .comment:hover & {\n    display: block;\n\n    .sticky-comment & {\n      display: none;\n    }\n  }\n\n  .comment--last:hover & {\n    display: none;\n  }\n}\n\n.comment__created {\n  opacity: 0.5;\n}\n\n.comment__buttons {\n  padding: 10px 5px 0;\n}\n\n.comment__button {\n  padding: 0 8px;\n  line-height: 28px;\n  height: 28px;\n}\n\n.comment__text {\n  position: relative;\n\n  &::before {\n    content: '';\n    position: absolute;\n    bottom: -8px;\n    right: 0;\n    border-top: 8px solid $editor-background-light;\n    border-left: 8px solid transparent;\n\n    .app--dark & {\n      border-top-color: $editor-background-dark;\n    }\n  }\n\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6 {\n    font-size: inherit;\n  }\n\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6,\n  p,\n  blockquote,\n  pre,\n  ul,\n  ol,\n  dl {\n    margin: 0.25em 0;\n  }\n\n  pre {\n    font-variant-ligatures: no-common-ligatures;\n    white-space: pre-wrap;\n    word-break: break-word;\n    word-wrap: break-word;\n    caret-color: #000;\n  }\n\n  img {\n    max-width: 100%;\n  }\n\n  .table-wrapper {\n    max-width: 100%;\n    overflow: auto;\n  }\n}\n\n.comment__text-inner {\n  min-height: 37px;\n  max-height: 200px;\n  overflow: auto;\n  padding: 1px 8px;\n  background-color: $editor-background-light;\n  border: 1px solid transparent;\n  border-radius: $border-radius-base;\n  border-bottom-right-radius: 0;\n\n  .app--dark & {\n    background-color: $editor-background-dark;\n  }\n\n  .markdown-highlighting {\n    padding: 5px 0;\n    margin: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/gutters/CurrentDiscussion.vue",
    "content": "<template>\n  <div class=\"current-discussion\" :style=\"{width: constants.gutterWidth + 'px'}\">\n    <sticky-comment v-if=\"stickyComment === 'bottom'\"></sticky-comment>\n    <div class=\"current-discussion__inner\">\n      <div class=\"flex flex--row flex--space-between\">\n        <div class=\"current-discussion__buttons flex flex--row flex--end\">\n          <button class=\"current-discussion__button button\" v-if=\"showNext\" @click=\"goToDiscussion(previousDiscussionId)\" v-title=\"'Previous discussion'\">\n            <icon-arrow-left></icon-arrow-left>\n          </button>\n          <button class=\"current-discussion__button current-discussion__button--rotate button\" v-if=\"showNext\" @click=\"goToDiscussion(nextDiscussionId)\" v-title=\"'Next discussion'\">\n            <icon-arrow-left></icon-arrow-left>\n          </button>\n        </div>\n        <div class=\"current-discussion__buttons flex flex--row flex--end\">\n          <button class=\"current-discussion__button current-discussion__button--remove button\" v-if=\"showRemove\" @click=\"removeDiscussion\" v-title=\"'Remove discussion'\">\n            <icon-delete></icon-delete>\n          </button>\n          <button class=\"current-discussion__button button\" @click=\"setCurrentDiscussionId()\" v-title=\"'Close discussion'\">\n            <icon-close></icon-close>\n          </button>\n        </div>\n      </div>\n      <div class=\"current-discussion__text markdown-highlighting markdown-highlighting--inline\">\n        <span @click=\"goToDiscussion()\" v-html=\"text\"></span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters, mapMutations, mapActions } from 'vuex';\nimport editorSvc from '../../services/editorSvc';\nimport animationSvc from '../../services/animationSvc';\nimport markdownConversionSvc from '../../services/markdownConversionSvc';\nimport StickyComment from './StickyComment';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default {\n  components: {\n    StickyComment,\n  },\n  computed: {\n    ...mapState('discussion', [\n      'stickyComment',\n      'currentDiscussionId',\n    ]),\n    ...mapGetters('discussion', [\n      'currentDiscussion',\n      'previousDiscussionId',\n      'nextDiscussionId',\n      'currentFileDiscussions',\n      'currentDiscussionLastCommentId',\n    ]),\n    ...mapGetters('layout', [\n      'constants',\n    ]),\n    text() {\n      return markdownConversionSvc.highlight(this.currentDiscussion.text);\n    },\n    showNext() {\n      return this.nextDiscussionId && this.nextDiscussionId !== this.currentDiscussionId;\n    },\n    showRemove() {\n      return this.currentDiscussionLastCommentId;\n    },\n  },\n  methods: {\n    ...mapMutations('discussion', [\n      'setCurrentDiscussionId',\n    ]),\n    ...mapActions('notification', [\n      'info',\n    ]),\n    goToDiscussion(discussionId = this.currentDiscussionId) {\n      this.setCurrentDiscussionId(discussionId);\n      const layoutSettings = store.getters['data/layoutSettings'];\n      const discussion = this.currentFileDiscussions[discussionId];\n      const coordinates = layoutSettings.showEditor\n        ? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)\n        : editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));\n      if (!coordinates) {\n        this.info(\"Discussion can't be located in the file.\");\n      } else {\n        const scrollerElt = layoutSettings.showEditor\n          ? editorSvc.editorElt.parentNode\n          : editorSvc.previewElt.parentNode;\n        let scrollTop = coordinates.top - (scrollerElt.offsetHeight / 2);\n        const maxScrollTop = scrollerElt.scrollHeight - scrollerElt.offsetHeight;\n        if (scrollTop < 0) {\n          scrollTop = 0;\n        } else if (scrollTop > maxScrollTop) {\n          scrollTop = maxScrollTop;\n        }\n        animationSvc.animate(scrollerElt)\n          .scrollTop(scrollTop)\n          .duration(200)\n          .start();\n      }\n    },\n    async removeDiscussion() {\n      try {\n        await store.dispatch('modal/open', 'discussionDeletion');\n        store.dispatch('discussion/cleanCurrentFile', {\n          filterDiscussion: this.currentDiscussion,\n        });\n        badgeSvc.addBadge('removeDiscussion');\n      } catch (e) {\n        // Cancel\n      }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.current-discussion {\n  position: absolute;\n  right: 0;\n  bottom: 0;\n\n  .sticky-comment {\n    position: relative;\n  }\n}\n\n.current-discussion__inner {\n  position: relative;\n  font-size: 16px;\n  background-color: $info-bg;\n  max-height: 130px; /* 3 lines max */\n  overflow: hidden;\n}\n\n.current-discussion__buttons {\n  padding: 4px 4px 0;\n}\n\n.current-discussion__button {\n  width: 30px;\n  height: 28px;\n  padding: 2px;\n  flex: none;\n  color: rgba(0, 0, 0, 0.5);\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.75);\n  }\n}\n\n.current-discussion__button--remove {\n  /* Make the trash a bit smaller */\n  padding: 3px;\n}\n\n.current-discussion__button--rotate {\n  transform: rotate(180deg);\n}\n\n.current-discussion__text {\n  padding: 10px;\n\n  span {\n    padding: 0.2em 0;\n    background-color: mix($editor-background-light, $selection-highlighting-color, 10%);\n    cursor: pointer;\n\n    .app--dark {\n      background-color: mix($editor-background-dark, $selection-highlighting-color, 10%);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/gutters/EditorNewDiscussionButton.vue",
    "content": "<template>\n  <a class=\"new-discussion-button\" href=\"javascript:void(0)\" v-if=\"coordinates\" :style=\"{top: coordinates.top + 'px'}\" v-title=\"'Start a discussion'\" @mousedown.stop.prevent @click=\"createNewDiscussion(selection)\">\n    <icon-message></icon-message>\n  </a>\n</template>\n\n<script>\nimport { mapActions } from 'vuex';\nimport editorSvc from '../../services/editorSvc';\nimport store from '../../store';\n\nexport default {\n  data: () => ({\n    selection: null,\n    coordinates: null,\n  }),\n  methods: {\n    ...mapActions('discussion', [\n      'createNewDiscussion',\n    ]),\n    checkSelection() {\n      clearTimeout(this.timeout);\n      this.timeout = setTimeout(() => {\n        let offset;\n        // Show the button if content is not a revision and has the focus\n        if (\n          !store.state.content.revisionContent &&\n          editorSvc.clEditor.selectionMgr.hasFocus()\n        ) {\n          this.selection = editorSvc.getTrimmedSelection();\n          if (this.selection) {\n            const text = editorSvc.clEditor.getContent();\n            offset = this.selection.end;\n            while (offset && text[offset - 1] === '\\n') {\n              offset -= 1;\n            }\n          }\n        }\n        this.coordinates = offset\n          ? editorSvc.clEditor.selectionMgr.getCoordinates(offset)\n          : null;\n      }, 25);\n    },\n  },\n  mounted() {\n    this.$nextTick(() => {\n      editorSvc.clEditor.selectionMgr.on('selectionChanged', () => this.checkSelection());\n      editorSvc.clEditor.selectionMgr.on('cursorCoordinatesChanged', () => this.checkSelection());\n      editorSvc.clEditor.on('focus', () => this.checkSelection());\n      editorSvc.clEditor.on('blur', () => this.checkSelection());\n      this.checkSelection();\n    });\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/gutters/NewComment.vue",
    "content": "<template>\n  <div class=\"comment comment--new\" @keydown.esc.stop=\"cancelNewComment\">\n    <div class=\"comment__header flex flex--row flex--space-between flex--align-center\">\n      <div class=\"comment__user flex flex--row flex--align-center\">\n        <div class=\"comment__user-image\">\n          <user-image :user-id=\"userId\"></user-image>\n        </div>\n        <span class=\"user-name\">{{loginToken.name}}</span>\n      </div>\n    </div>\n    <div class=\"comment__text\">\n      <div class=\"comment__text-inner\">\n        <pre class=\"markdown-highlighting\"></pre>\n      </div>\n    </div>\n    <div class=\"comment__buttons flex flex--row flex--end\">\n      <button class=\"comment__button button\" @click=\"cancelNewComment\">Cancel</button>\n      <button class=\"comment__button button\" @click=\"addComment\">Ok</button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapGetters, mapMutations, mapActions } from 'vuex';\nimport Prism from 'prismjs';\nimport UserImage from '../UserImage';\nimport cledit from '../../services/editor/cledit';\nimport editorSvc from '../../services/editorSvc';\nimport markdownConversionSvc from '../../services/markdownConversionSvc';\nimport utils from '../../services/utils';\nimport userSvc from '../../services/userSvc';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default {\n  components: {\n    UserImage,\n  },\n  computed: {\n    ...mapGetters('workspace', [\n      'loginToken',\n    ]),\n    userId() {\n      return userSvc.getCurrentUserId();\n    },\n  },\n  methods: {\n    ...mapMutations('discussion', [\n      'setNewCommentFocus',\n    ]),\n    ...mapActions('discussion', [\n      'cancelNewComment',\n    ]),\n    addComment() {\n      const text = store.state.discussion.newCommentText.trim();\n      if (text.length) {\n        if (text.length > 2000) {\n          store.dispatch('notification/error', 'Comment is too long.');\n        } else {\n          // Create comment\n          const discussionId = store.state.discussion.currentDiscussionId;\n          const comment = {\n            discussionId,\n            sub: this.userId,\n            text,\n            created: Date.now(),\n          };\n          const patch = {\n            comments: {\n              ...store.getters['content/current'].comments,\n              [utils.uid()]: comment,\n            },\n          };\n          if (discussionId === store.state.discussion.newDiscussionId) {\n            // Create discussion\n            patch.discussions = {\n              ...store.getters['content/current'].discussions,\n              [discussionId]: store.getters['discussion/newDiscussion'],\n            };\n            badgeSvc.addBadge('createDiscussion');\n          } else {\n            badgeSvc.addBadge('addComment');\n          }\n          store.dispatch('content/patchCurrent', patch);\n          store.commit('discussion/setNewCommentText');\n          store.commit('discussion/setIsCommenting');\n        }\n      }\n    },\n  },\n  mounted() {\n    const preElt = this.$el.querySelector('pre.markdown-highlighting');\n    const scrollerElt = this.$el.querySelector('.comment__text-inner');\n    const clEditor = cledit(preElt, scrollerElt, true);\n    clEditor.init({\n      sectionHighlighter: section => Prism.highlight(\n        section.text,\n        editorSvc.prismGrammars[section.data],\n      ),\n      sectionParser: text => markdownConversionSvc\n        .parseSections(editorSvc.converter, text).sections,\n      content: store.state.discussion.newCommentText,\n      selectionStart: store.state.discussion.newCommentSelection.start,\n      selectionEnd: store.state.discussion.newCommentSelection.end,\n      getCursorFocusRatio: () => 0.2,\n    });\n    clEditor.on('focus', () => this.setNewCommentFocus(true));\n\n    // Save typed content and selection\n    clEditor.on('contentChanged', value =>\n      store.commit('discussion/setNewCommentText', value));\n    clEditor.selectionMgr.on('selectionChanged', (start, end) =>\n      store.commit('discussion/setNewCommentSelection', {\n        start, end,\n      }));\n\n    const isSticky = this.$el.parentNode.classList.contains('sticky-comment');\n    const isVisible = () => isSticky || store.state.discussion.stickyComment === null;\n\n    this.$watch(\n      () => store.state.discussion.currentDiscussionId,\n      () => this.$nextTick(() => {\n        if (isVisible() && store.state.discussion.newCommentFocus) {\n          clEditor.focus();\n        }\n      }),\n      { immediate: true },\n    );\n\n    if (isSticky) {\n      let scrollerMirrorElt;\n      const getScrollerMirrorElt = () => {\n        if (!scrollerMirrorElt) {\n          scrollerMirrorElt = document.querySelector('.comment-list .comment--new .comment__text-inner');\n        }\n        return scrollerMirrorElt || { scrollTop: 0 };\n      };\n\n      scrollerElt.scrollTop = getScrollerMirrorElt().scrollTop;\n      scrollerElt.addEventListener('scroll', () => {\n        getScrollerMirrorElt().scrollTop = scrollerElt.scrollTop;\n      });\n    } else {\n      // Maintain the state with the sticky comment\n      this.$watch(\n        () => isVisible(),\n        (visible) => {\n          clEditor.toggleEditable(visible);\n          if (visible) {\n            const text = store.state.discussion.newCommentText;\n            clEditor.setContent(text);\n            const selection = store.state.discussion.newCommentSelection;\n            clEditor.selectionMgr.setSelectionStartEnd(selection.start, selection.end);\n            if (store.state.discussion.newCommentFocus) {\n              clEditor.focus();\n            }\n          }\n        },\n        { immediate: true },\n      );\n      this.$watch(\n        () => store.state.discussion.newCommentText,\n        newCommentText => clEditor.setContent(newCommentText),\n      );\n    }\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/gutters/PreviewNewDiscussionButton.vue",
    "content": "<template>\n  <a class=\"new-discussion-button\" href=\"javascript:void(0)\" v-if=\"coordinates\" :style=\"{top: coordinates.top + 'px'}\" v-title=\"'Start a discussion'\" @mousedown.stop.prevent @click=\"createNewDiscussion(selection)\">\n    <icon-message></icon-message>\n  </a>\n</template>\n\n<script>\nimport { mapActions } from 'vuex';\nimport editorSvc from '../../services/editorSvc';\nimport store from '../../store';\n\nexport default {\n  data: () => ({\n    selection: null,\n    coordinates: null,\n  }),\n  methods: {\n    ...mapActions('discussion', [\n      'createNewDiscussion',\n    ]),\n    checkSelection() {\n      clearTimeout(this.timeout);\n      this.timeout = setTimeout(() => {\n        let offset;\n        // Show the button if content is not a revision and preview selection is not empty\n        if (\n          !store.state.content.revisionContent &&\n          editorSvc.previewSelectionRange\n        ) {\n          this.selection = editorSvc.getTrimmedSelection();\n          if (this.selection) {\n            const { text } = editorSvc.previewCtxWithDiffs;\n            offset = editorSvc.getPreviewOffset(this.selection.end);\n            while (offset && text[offset - 1] === '\\n') {\n              offset -= 1;\n            }\n          }\n        }\n        this.coordinates = offset\n          ? editorSvc.getPreviewOffsetCoordinates(offset)\n          : null;\n      }, 25);\n    },\n  },\n  mounted() {\n    this.$nextTick(() => {\n      editorSvc.$on('previewSelectionRange', () => this.checkSelection());\n      this.$watch(\n        () => store.getters['layout/styles'].previewWidth,\n        () => this.checkSelection(),\n      );\n      this.checkSelection();\n    });\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/gutters/StickyComment.vue",
    "content": "<template>\n  <div class=\"sticky-comment\" :style=\"{width: constants.gutterWidth + 'px', top: top + 'px'}\">\n    <comment v-if=\"currentDiscussionLastComment\" :comment=\"currentDiscussionLastComment\"></comment>\n    <new-comment v-if=\"isCommenting\"></new-comment>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters } from 'vuex';\nimport Comment from './Comment';\nimport NewComment from './NewComment';\n\nexport default {\n  components: {\n    Comment,\n    NewComment,\n  },\n  data: () => ({\n    top: 0,\n  }),\n  computed: {\n    ...mapGetters('layout', [\n      'constants',\n    ]),\n    ...mapState('discussion', [\n      'isCommenting',\n    ]),\n    ...mapGetters('discussion', [\n      'currentDiscussionLastComment',\n    ]),\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.sticky-comment {\n  position: absolute;\n  right: 0;\n  font-size: 15px;\n  padding-top: 10px;\n\n  .current-discussion & {\n    width: auto !important;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/menus/HistoryMenu.vue",
    "content": "<template>\n  <div class=\"history side-bar__panel side-bar__panel--menu\">\n    <div class=\"side-bar__info\">\n      <p v-if=\"syncLocations.length > 1\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"syncLocationId\" @keydown.enter=\"resolve()\">\n          <option v-for=\"location in syncLocations\" :key=\"location.id\" :value=\"location.id\">\n            {{ location.description }}\n          </option>\n        </select>\n      </p>\n      <p v-if=\"!historyContext\">Synchronize <b>{{currentFileName}}</b> to enable revision history or <a href=\"javascript:void(0)\" @click=\"signin\">sign in with Google</a> to synchronize your main workspace.</p>\n      <p v-else-if=\"loading\">Loading history…</p>\n      <p v-else-if=\"!revisionsWithSpacer.length\"><b>{{currentFileName}}</b> has no history.</p>\n      <div class=\"menu-entry menu-entry--info flex flex--row flex--align-center\" v-else>\n        <div class=\"menu-entry__icon menu-entry__icon--image\">\n          <icon-provider :provider-id=\"syncLocation.providerId\"></icon-provider>\n        </div>\n        <span v-if=\"syncLocation.url\">\n          The following revisions are stored in <a :href=\"syncLocation.url\" target=\"_blank\">{{ syncLocationProviderName }}</a>.\n        </span>\n        <span v-else>\n          The following revisions are stored in {{ syncLocationProviderName }}.\n        </span>\n      </div>\n    </div>\n    <div>\n      <div class=\"revision\" v-for=\"revision in revisionsWithSpacer\" :key=\"revision.id\">\n        <div class=\"history__spacer\" v-if=\"revision.spacer\"></div>\n        <a class=\"revision__button button flex flex--row\" href=\"javascript:void(0)\" @click=\"open(revision)\">\n          <div class=\"revision__icon\">\n            <user-image :user-id=\"revision.sub\"></user-image>\n          </div>\n          <div class=\"revision__header flex flex--column\">\n            <user-name :user-id=\"revision.sub\"></user-name>\n            <div class=\"revision__created\">{{revision.created | formatTime}}</div>\n          </div>\n        </a>\n      </div>\n    </div>\n    <div class=\"history__spacer history__spacer--last\" v-if=\"revisions.length\"></div>\n    <div class=\"flex flex--row flex--end\" v-if=\"showMoreButton\">\n      <button class=\"history__button button\" @click=\"showMore\">More</button>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapMutations, mapGetters } from 'vuex';\nimport providerRegistry from '../../services/providers/common/providerRegistry';\nimport MenuEntry from './common/MenuEntry';\nimport UserImage from '../UserImage';\nimport UserName from '../UserName';\nimport EditorClassApplier from '../common/EditorClassApplier';\nimport PreviewClassApplier from '../common/PreviewClassApplier';\nimport utils from '../../services/utils';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport syncSvc from '../../services/syncSvc';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nlet editorClassAppliers = [];\nlet previewClassAppliers = [];\n\nlet cachedHistoryContextHash;\nlet revisionsPromise;\nlet revisionContentPromises;\nconst pageSize = 30;\nconst spacerThreshold = 6 * 60 * 60 * 1000; // 6h\n\nexport default {\n  components: {\n    MenuEntry,\n    UserImage,\n    UserName,\n  },\n  data: () => ({\n    allRevisions: [],\n    loading: false,\n    showCount: pageSize,\n    syncLocationId: null,\n  }),\n  computed: {\n    ...mapGetters('data', [\n      'syncDataByItemId',\n    ]),\n    ...mapGetters('syncLocation', {\n      syncLocations: 'currentWithWorkspaceSyncLocation',\n    }),\n    ...mapState('content', [\n      'revisionContent',\n    ]),\n    syncLocation() {\n      return utils.someResult(this.syncLocations, (syncLocation) => {\n        if (syncLocation.id === this.syncLocationId) {\n          return syncLocation;\n        }\n        return null;\n      });\n    },\n    syncLocationProviderName() {\n      if (!this.syncLocation) {\n        return null;\n      }\n      return providerRegistry.providersById[this.syncLocation.providerId].name;\n    },\n    currentFileName() {\n      return store.getters['file/current'].name;\n    },\n    historyContext() {\n      const { syncLocation } = this;\n      if (syncLocation) {\n        const provider = providerRegistry.providersById[syncLocation.providerId];\n        const token = provider.getToken(syncLocation);\n        const fileId = store.getters['file/current'].id;\n        const contentId = `${fileId}/content`;\n        const historyContext = {\n          token,\n          fileId,\n          contentId,\n          syncLocation: this.syncLocation,\n        };\n        if (syncLocation.id !== 'main') {\n          return historyContext;\n        }\n\n        // Add syncData for workspace sync location\n        const { syncDataByItemId } = this;\n        const fileSyncData = syncDataByItemId[fileId];\n        const contentSyncData = syncDataByItemId[contentId];\n        if (fileSyncData && contentSyncData) {\n          return {\n            ...historyContext,\n            fileSyncDataId: fileSyncData.id,\n            contentSyncDataId: contentSyncData.id,\n          };\n        }\n      }\n      return null;\n    },\n    historyContextHash() {\n      return utils.serializeObject(this.historyContext);\n    },\n    revisions() {\n      return this.allRevisions.slice()\n        .sort((revision1, revision2) => revision2.created - revision1.created)\n        .slice(0, this.showCount);\n    },\n    revisionsWithSpacer() {\n      let previousCreated = 0;\n      return this.revisions.map((revision) => {\n        const revisionWithSpacer = {\n          ...revision,\n          spacer: revision.created + spacerThreshold < previousCreated,\n        };\n        previousCreated = revision.created;\n        return revisionWithSpacer;\n      });\n    },\n    showMoreButton() {\n      return this.showCount < this.allRevisions.length;\n    },\n  },\n  methods: {\n    ...mapMutations('content', [\n      'setRevisionContent',\n    ]),\n    async signin() {\n      try {\n        await googleHelper.signin();\n        syncSvc.requestSync();\n      } catch (e) {\n        // Cancel\n      }\n    },\n    close() {\n      store.dispatch('data/setSideBarPanel', 'menu');\n    },\n    showMore() {\n      this.showCount += pageSize;\n    },\n    open(revision) {\n      let revisionContentPromise = revisionContentPromises[revision.id];\n      if (!revisionContentPromise) {\n        const historyContext = utils.deepCopy(this.historyContext);\n        if (historyContext) {\n          const provider = providerRegistry.providersById[this.syncLocation.providerId];\n          revisionContentPromise = new Promise((resolve, reject) => store.dispatch(\n            'queue/enqueue',\n            () => provider.getFileRevisionContent({\n              ...historyContext,\n              revisionId: revision.id,\n            })\n              .then(resolve, reject),\n          ));\n          revisionContentPromises[revision.id] = revisionContentPromise;\n          revisionContentPromise.catch((err) => {\n            store.dispatch('notification/error', err);\n            revisionContentPromises[revision.id] = null;\n          });\n        }\n      }\n      if (revisionContentPromise) {\n        revisionContentPromise.then(revisionContent =>\n          store.dispatch('content/setRevisionContent', revisionContent));\n      }\n    },\n    refreshHighlighters() {\n      const { revisionContent } = this;\n      editorClassAppliers.forEach(editorClassApplier => editorClassApplier.stop());\n      editorClassAppliers = [];\n      previewClassAppliers.forEach(previewClassApplier => previewClassApplier.stop());\n      previewClassAppliers = [];\n      if (revisionContent) {\n        let offset = 0;\n        revisionContent.diffs.forEach(([type, text]) => {\n          if (type) {\n            const classes = ['revision-diff', `revision-diff--${type > 0 ? 'insert' : 'delete'}`];\n            const offsets = {\n              start: offset,\n              end: offset + text.length,\n            };\n            editorClassAppliers.push(new EditorClassApplier(\n              [`revision-diff--${utils.uid()}`, ...classes],\n              offsets,\n            ));\n            previewClassAppliers.push(new PreviewClassApplier(\n              [`revision-diff--${utils.uid()}`, ...classes],\n              offsets,\n            ));\n          }\n          offset += text.length;\n        });\n      }\n    },\n  },\n  watch: {\n    // Fix syncLocationId\n    syncLocation: {\n      immediate: true,\n      handler(value) {\n        const firstSyncLocation = this.syncLocations[0];\n        if (firstSyncLocation) {\n          if (!value) {\n            this.syncLocationId = firstSyncLocation.id;\n          } else if (value.id !== firstSyncLocation.id) {\n            badgeSvc.addBadge('chooseHistory');\n          }\n        }\n      },\n    },\n    // Load revision list on context changes\n    historyContextHash: {\n      immediate: true,\n      handler() {\n        this.allRevisions = [];\n        const historyContext = utils.deepCopy(this.historyContext);\n        if (historyContext) {\n          if (this.historyContextHash !== cachedHistoryContextHash) {\n            this.setRevisionContent();\n            cachedHistoryContextHash = this.historyContextHash;\n            revisionContentPromises = {};\n            const provider = providerRegistry.providersById[this.syncLocation.providerId];\n            revisionsPromise = new Promise((resolve, reject) => store.dispatch(\n              'queue/enqueue',\n              () => provider\n                .listFileRevisions(historyContext)\n                .then(resolve, reject),\n            ))\n              .catch((err) => {\n                store.dispatch('notification/error', err);\n                cachedHistoryContextHash = null;\n                return [];\n              });\n          }\n          if (revisionsPromise) {\n            this.loading = true;\n            revisionsPromise.then((revisions) => {\n              this.loading = false;\n              this.allRevisions = revisions;\n            });\n          }\n        }\n      },\n    },\n    // Load each revision on revision list changes\n    revisions(revisions) {\n      const { historyContext } = this;\n      if (historyContext) {\n        store.dispatch(\n          'queue/enqueue',\n          () => utils.awaitSequence(revisions, async (revision) => {\n            // Make sure revisions and historyContext haven't changed\n            if (!this.destroyed\n              && this.revisions === revisions\n              && this.historyContext === historyContext\n            ) {\n              const provider = providerRegistry.providersById[this.syncLocation.providerId];\n              await provider.loadFileRevision({\n                ...historyContext,\n                revision,\n              });\n            }\n          }),\n        );\n      }\n    },\n    // Refresh highlighters on open/close revision\n    revisionContent: {\n      immediate: true,\n      handler() {\n        this.refreshHighlighters();\n      },\n    },\n  },\n  created() {\n    // Close revision on escape\n    this.onKeyup = (evt) => {\n      if (evt.which === 27) {\n        // Esc key\n        this.setRevisionContent();\n      }\n    };\n    window.addEventListener('keyup', this.onKeyup);\n  },\n  destroyed() {\n    // Close revision\n    this.setRevisionContent();\n    // Remove highlighters\n    this.refreshHighlighters();\n    // Remove event listener\n    window.removeEventListener('keyup', this.onKeyup);\n    // Cancel loading revisions\n    this.destroyed = true;\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.history__button {\n  font-size: 14px;\n  margin-top: 0.5em;\n}\n\n.history__spacer {\n  position: relative;\n  height: 40px;\n\n  &::before {\n    content: '';\n    position: absolute;\n    height: 100%;\n    top: 0;\n    left: 19px;\n    border-left: 2px dotted $hr-color;\n  }\n}\n\n.history__spacer--last {\n  height: 20px;\n}\n\n.revision__button {\n  text-align: left;\n  padding: 10px;\n  height: auto;\n  text-transform: none;\n  position: relative;\n\n  &::before {\n    content: '';\n    position: absolute;\n    height: 100%;\n    top: 0;\n    left: 19px;\n    border-left: 2px solid $hr-color;\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    &::before {\n      display: none;\n    }\n  }\n\n  .revision:first-child &::before {\n    height: 67%;\n    top: 33%;\n  }\n}\n\n.revision__icon {\n  height: 20px;\n  width: 20px;\n  margin-right: 12px;\n  flex: none;\n  border-radius: $border-radius-base;\n  overflow: hidden;\n  position: relative;\n}\n\n.revision__header {\n  font-size: 15px;\n  width: 100%;\n  line-height: 1.33;\n}\n\n.revision__created {\n  font-size: 0.75em;\n  opacity: 0.6;\n}\n\n.layout--revision {\n  .cledit-section *,\n  .cl-preview-section * {\n    color: transparentize($editor-color-light, 0.5) !important;\n\n    .app--dark & {\n      color: transparentize($editor-color-dark, 0.5) !important;\n    }\n  }\n\n  .cledit-section .revision-diff {\n    color: $editor-color-light !important;\n\n    .app--dark & {\n      color: $editor-color-dark !important;\n    }\n  }\n\n  .cl-preview-section .revision-diff {\n    color: $body-color-light !important;\n\n    .app--dark & {\n      color: $body-color-dark !important;\n    }\n  }\n\n  .revision-diff {\n    padding: 0.25em 0;\n\n    &.revision-diff--insert {\n      background-color: mix(#fff, $selection-highlighting-color, 60%);\n    }\n\n    &.revision-diff--delete {\n      background-color: mix(#fff, $error-color, 60%);\n      text-decoration: line-through;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/menus/ImportExportMenu.vue",
    "content": "<template>\n  <div class=\"side-bar__panel side-bar__panel--menu\">\n    <input class=\"hidden-file\" id=\"import-markdown-file-input\" type=\"file\" @change=\"onImportMarkdown\">\n    <label class=\"menu-entry button flex flex--row flex--align-center\" for=\"import-markdown-file-input\">\n      <div class=\"menu-entry__icon flex flex--column flex--center\">\n        <icon-upload></icon-upload>\n      </div>\n      <div class=\"flex flex--column\">\n        <div>Import Markdown</div>\n        <span>Import a plain text file.</span>\n      </div>\n    </label>\n    <input class=\"hidden-file\" id=\"import-html-file-input\" type=\"file\" @change=\"onImportHtml\">\n    <label class=\"menu-entry button flex flex--row flex--align-center\" for=\"import-html-file-input\">\n      <div class=\"menu-entry__icon flex flex--column flex--center\">\n        <icon-upload></icon-upload>\n      </div>\n      <div class=\"flex flex--column\">\n        <div>Import HTML</div>\n        <span>Convert an HTML file to Markdown.</span>\n      </div>\n    </label>\n    <hr>\n    <menu-entry @click.native=\"exportMarkdown\">\n      <icon-download slot=\"icon\"></icon-download>\n      <div>Export as Markdown</div>\n      <span>Save plain text file.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"exportHtml\">\n      <icon-download slot=\"icon\"></icon-download>\n      <div>Export as HTML</div>\n      <span>Generate an HTML page from a template.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"exportPdf\">\n      <icon-download slot=\"icon\"></icon-download>\n      <div><div class=\"menu-entry__label\" :class=\"{'menu-entry__label--warning': !isSponsor}\">sponsor</div> Export as PDF</div>\n      <span>Produce a PDF from an HTML template.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"exportPandoc\">\n      <icon-download slot=\"icon\"></icon-download>\n      <div><div class=\"menu-entry__label\" :class=\"{'menu-entry__label--warning': !isSponsor}\">sponsor</div> Export with Pandoc</div>\n      <span>Convert to PDF, Word, EPUB...</span>\n    </menu-entry>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport TurndownService from 'turndown/lib/turndown.browser.umd';\nimport htmlSanitizer from '../../libs/htmlSanitizer';\nimport MenuEntry from './common/MenuEntry';\nimport Provider from '../../services/providers/common/Provider';\nimport store from '../../store';\nimport workspaceSvc from '../../services/workspaceSvc';\nimport exportSvc from '../../services/exportSvc';\nimport badgeSvc from '../../services/badgeSvc';\n\nconst turndownService = new TurndownService(store.getters['data/computedSettings'].turndown);\n\nconst readFile = file => new Promise((resolve) => {\n  if (file) {\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      const content = e.target.result;\n      if (content.match(/\\uFFFD/)) {\n        store.dispatch('notification/error', 'File is not readable.');\n      } else {\n        resolve(content);\n      }\n    };\n    reader.readAsText(file);\n  }\n});\n\nexport default {\n  components: {\n    MenuEntry,\n  },\n  computed: mapGetters(['isSponsor']),\n  methods: {\n    async onImportMarkdown(evt) {\n      const file = evt.target.files[0];\n      const content = await readFile(file);\n      const item = await workspaceSvc.createFile({\n        ...Provider.parseContent(content),\n        name: file.name,\n      });\n      store.commit('file/setCurrentId', item.id);\n      badgeSvc.addBadge('importMarkdown');\n    },\n    async onImportHtml(evt) {\n      const file = evt.target.files[0];\n      const content = await readFile(file);\n      const sanitizedContent = htmlSanitizer.sanitizeHtml(content)\n        .replace(/&#160;/g, ' '); // Replace non-breaking spaces with classic spaces\n      const item = await workspaceSvc.createFile({\n        ...Provider.parseContent(turndownService.turndown(sanitizedContent)),\n        name: file.name,\n      });\n      store.commit('file/setCurrentId', item.id);\n      badgeSvc.addBadge('importHtml');\n    },\n    async exportMarkdown() {\n      const currentFile = store.getters['file/current'];\n      try {\n        await exportSvc.exportToDisk(currentFile.id, 'md');\n        badgeSvc.addBadge('exportMarkdown');\n      } catch (e) { /* Cancel */ }\n    },\n    async exportHtml() {\n      try {\n        await store.dispatch('modal/open', 'htmlExport');\n      } catch (e) { /* Cancel */ }\n    },\n    async exportPdf() {\n      try {\n        await store.dispatch('modal/open', 'pdfExport');\n      } catch (e) { /* Cancel */ }\n    },\n    async exportPandoc() {\n      try {\n        await store.dispatch('modal/open', 'pandocExport');\n      } catch (e) { /* Cancel */ }\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/menus/MainMenu.vue",
    "content": "<template>\n  <div class=\"side-bar__panel side-bar__panel--menu\">\n    <div class=\"side-bar__info\">\n      <div class=\"menu-entry menu-entry--info flex flex--row flex--align-center\" v-if=\"loginToken\">\n        <div class=\"menu-entry__icon menu-entry__icon--image\">\n          <user-image :user-id=\"userId\"></user-image>\n        </div>\n        <span>Signed in as <b>{{loginToken.name}}</b>.</span>\n      </div>\n      <div class=\"menu-entry menu-entry--info flex flex--row flex--align-center\" v-if=\"syncToken\">\n        <div class=\"menu-entry__icon menu-entry__icon--image\">\n          <icon-provider :provider-id=\"currentWorkspace.providerId\"></icon-provider>\n        </div>\n        <span v-if=\"currentWorkspace.providerId === 'googleDriveAppData'\">\n          <b>{{currentWorkspace.name}}</b> synced with your Google Drive app data folder.\n        </span>\n        <span v-else-if=\"currentWorkspace.providerId === 'googleDriveWorkspace'\">\n          <b>{{currentWorkspace.name}}</b> synced with a <a :href=\"workspaceLocationUrl\" target=\"_blank\">Google Drive folder</a>.\n        </span>\n        <span v-else-if=\"currentWorkspace.providerId === 'couchdbWorkspace'\">\n          <b>{{currentWorkspace.name}}</b> synced with a <a :href=\"workspaceLocationUrl\" target=\"_blank\">CouchDB database</a>.\n        </span>\n        <span v-else-if=\"currentWorkspace.providerId === 'githubWorkspace'\">\n          <b>{{currentWorkspace.name}}</b> synced with a <a :href=\"workspaceLocationUrl\" target=\"_blank\">GitHub repo</a>.\n        </span>\n        <span v-else-if=\"currentWorkspace.providerId === 'gitlabWorkspace'\">\n          <b>{{currentWorkspace.name}}</b> synced with a <a :href=\"workspaceLocationUrl\" target=\"_blank\">GitLab project</a>.\n        </span>\n      </div>\n      <div class=\"menu-entry menu-entry--info flex flex--row flex--align-center\" v-else>\n        <div class=\"menu-entry__icon menu-entry__icon--disabled\">\n          <icon-sync-off></icon-sync-off>\n        </div>\n        <span><b>{{currentWorkspace.name}}</b> not synced.</span>\n      </div>\n    </div>\n    <menu-entry v-if=\"!loginToken\" @click.native=\"signin\">\n      <icon-login slot=\"icon\"></icon-login>\n      <div>Sign in with Google</div>\n      <span>Sync your main workspace and unlock functionalities.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"setPanel('workspaces')\">\n      <icon-database slot=\"icon\"></icon-database>\n      <div><div class=\"menu-entry__label menu-entry__label--count\" v-if=\"workspaceCount\">{{workspaceCount}}</div> Workspaces</div>\n      <span>Switch to another workspace.</span>\n    </menu-entry>\n    <hr>\n    <menu-entry @click.native=\"setPanel('sync')\">\n      <icon-sync slot=\"icon\"></icon-sync>\n      <div><div class=\"menu-entry__label menu-entry__label--count\" v-if=\"syncLocationCount\">{{syncLocationCount}}</div> Synchronize</div>\n      <span>Sync your files in the Cloud.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"setPanel('publish')\">\n      <icon-upload slot=\"icon\"></icon-upload>\n      <div><div class=\"menu-entry__label menu-entry__label--count\" v-if=\"publishLocationCount\">{{publishLocationCount}}</div>Publish</div>\n      <span>Export your files to the web.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"setPanel('history')\">\n      <icon-history slot=\"icon\"></icon-history>\n      <div>History</div>\n      <span>Track and restore file revisions.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"fileProperties\">\n      <icon-view-list slot=\"icon\"></icon-view-list>\n      <div>File properties</div>\n      <span>Add metadata and configure extensions.</span>\n    </menu-entry>\n    <hr>\n    <menu-entry @click.native=\"setPanel('toc')\">\n      <icon-toc slot=\"icon\"></icon-toc>\n      Table of contents\n    </menu-entry>\n    <menu-entry @click.native=\"setPanel('help')\">\n      <icon-help-circle slot=\"icon\"></icon-help-circle>\n      Markdown cheat sheet\n    </menu-entry>\n    <hr>\n    <menu-entry @click.native=\"setPanel('importExport')\">\n      <icon-content-save slot=\"icon\"></icon-content-save>\n      Import/export\n    </menu-entry>\n    <menu-entry @click.native=\"print\">\n      <icon-printer slot=\"icon\"></icon-printer>\n      Print\n    </menu-entry>\n    <hr>\n    <menu-entry @click.native=\"badges\">\n      <icon-seal slot=\"icon\"></icon-seal>\n      <div><div class=\"menu-entry__label menu-entry__label--count\">{{badgeCount}}/{{featureCount}}</div> Badges</div>\n      <span>List application features and earned badges.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"accounts\">\n      <icon-key slot=\"icon\"></icon-key>\n      <div><div class=\"menu-entry__label menu-entry__label--count\">{{accountCount}}</div> Accounts</div>\n      <span>Manage access to your external accounts.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"templates\">\n      <icon-code-braces slot=\"icon\"></icon-code-braces>\n      <div><div class=\"menu-entry__label menu-entry__label--count\">{{templateCount}}</div> Templates</div>\n      <span>Configure Handlebars templates for your exports.</span>\n    </menu-entry>\n    <menu-entry @click.native=\"settings\">\n      <icon-settings slot=\"icon\"></icon-settings>\n      <div>Settings</div>\n      <span>Tweak application and keyboard shortcuts.</span>\n    </menu-entry>\n    <hr>\n    <menu-entry @click.native=\"setPanel('workspaceBackups')\">\n      <icon-content-save slot=\"icon\"></icon-content-save>\n      Workspace backups\n    </menu-entry>\n    <menu-entry @click.native=\"reset\">\n      <icon-logout slot=\"icon\"></icon-logout>\n      Reset application\n    </menu-entry>\n    <menu-entry @click.native=\"about\">\n      <icon-help-circle slot=\"icon\"></icon-help-circle>\n      About StackEdit\n    </menu-entry>\n  </div>\n</template>\n\n<script>\nimport { mapGetters, mapActions } from 'vuex';\nimport MenuEntry from './common/MenuEntry';\nimport providerRegistry from '../../services/providers/common/providerRegistry';\nimport UserImage from '../UserImage';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport syncSvc from '../../services/syncSvc';\nimport userSvc from '../../services/userSvc';\nimport store from '../../store';\n\nexport default {\n  components: {\n    MenuEntry,\n    UserImage,\n  },\n  computed: {\n    ...mapGetters('workspace', [\n      'currentWorkspace',\n      'syncToken',\n      'loginToken',\n    ]),\n    userId() {\n      return userSvc.getCurrentUserId();\n    },\n    workspaceLocationUrl() {\n      const provider = providerRegistry.providersById[this.currentWorkspace.providerId];\n      return provider.getWorkspaceLocationUrl(this.currentWorkspace);\n    },\n    workspaceCount() {\n      return Object.keys(store.getters['workspace/workspacesById']).length;\n    },\n    syncLocationCount() {\n      return Object.keys(store.getters['syncLocation/currentWithWorkspaceSyncLocation']).length;\n    },\n    publishLocationCount() {\n      return Object.keys(store.getters['publishLocation/current']).length;\n    },\n    templateCount() {\n      return Object.keys(store.getters['data/allTemplatesById']).length;\n    },\n    accountCount() {\n      return Object.values(store.getters['data/tokensByType'])\n        .reduce((count, tokensBySub) => count + Object.values(tokensBySub).length, 0);\n    },\n    badgeCount() {\n      return store.getters['data/allBadges'].filter(badge => badge.isEarned).length;\n    },\n    featureCount() {\n      return store.getters['data/allBadges'].length;\n    },\n  },\n  methods: {\n    ...mapActions('data', {\n      setPanel: 'setSideBarPanel',\n    }),\n    async signin() {\n      try {\n        await googleHelper.signin();\n        syncSvc.requestSync();\n      } catch (e) {\n        // Cancel\n      }\n    },\n    async fileProperties() {\n      try {\n        await store.dispatch('modal/open', 'fileProperties');\n      } catch (e) {\n        // Cancel\n      }\n    },\n    print() {\n      window.print();\n    },\n    async settings() {\n      try {\n        await store.dispatch('modal/open', 'settings');\n      } catch (e) { /* Cancel */ }\n    },\n    async templates() {\n      try {\n        await store.dispatch('modal/open', 'templates');\n      } catch (e) { /* Cancel */ }\n    },\n    async accounts() {\n      try {\n        await store.dispatch('modal/open', 'accountManagement');\n      } catch (e) { /* Cancel */ }\n    },\n    async badges() {\n      try {\n        await store.dispatch('modal/open', 'badgeManagement');\n      } catch (e) { /* Cancel */ }\n    },\n    async reset() {\n      try {\n        await store.dispatch('modal/open', 'reset');\n        localStorage.setItem('resetStackEdit', '1');\n        window.location.reload();\n      } catch (e) { /* Cancel */ }\n    },\n    about() {\n      store.dispatch('modal/open', 'about');\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/menus/PublishMenu.vue",
    "content": "<template>\n  <div class=\"side-bar__panel side-bar__panel--menu\">\n    <div class=\"side-bar__info\" v-if=\"isCurrentTemp\">\n      <p>{{currentFileName}} can't be published as it's a temporary file.</p>\n    </div>\n    <div v-else>\n      <div class=\"side-bar__info\" v-if=\"publishLocations.length\">\n        <p>{{currentFileName}} is already published.</p>\n        <menu-entry @click.native=\"requestPublish\">\n          <icon-upload slot=\"icon\"></icon-upload>\n          <div>Publish now</div>\n          <span>Update publications for {{currentFileName}}.</span>\n        </menu-entry>\n        <menu-entry @click.native=\"managePublish\">\n          <icon-view-list slot=\"icon\"></icon-view-list>\n          <div><div class=\"menu-entry__label menu-entry__label--count\">{{locationCount}}</div> File publication</div>\n          <span>Manage publication locations for {{currentFileName}}.</span>\n        </menu-entry>\n      </div>\n      <div class=\"side-bar__info\" v-else-if=\"noToken\">\n        <p>You have to link an account to start publishing files.</p>\n      </div>\n      <hr>\n      <div v-for=\"token in bloggerTokens\" :key=\"'blogger-' + token.sub\">\n        <menu-entry @click.native=\"publishBlogger(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"blogger\"></icon-provider>\n          <div>Publish to Blogger</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"publishBloggerPage(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"bloggerPage\"></icon-provider>\n          <div>Publish to Blogger Page</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in dropboxTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"publishDropbox(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"dropbox\"></icon-provider>\n          <div>Publish to Dropbox</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in githubTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"publishGist(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"gist\"></icon-provider>\n          <div>Publish to Gist</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"publishGithub(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"github\"></icon-provider>\n          <div>Publish to GitHub</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in gitlabTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"publishGitlab(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"gitlab\"></icon-provider>\n          <div>Publish to GitLab</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in googleDriveTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"publishGoogleDrive(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"googleDrive\"></icon-provider>\n          <div>Publish to Google Drive</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in wordpressTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"publishWordpress(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"wordpress\"></icon-provider>\n          <div>Publish to WordPress</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in zendeskTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"publishZendesk(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"zendesk\"></icon-provider>\n          <div>Publish to Zendesk Help Center</div>\n          <span>{{token.name}} — {{token.subdomain}}</span>\n        </menu-entry>\n      </div>\n      <hr>\n      <menu-entry @click.native=\"addBloggerAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"blogger\"></icon-provider>\n        <span>Add Blogger account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addDropboxAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"dropbox\"></icon-provider>\n        <span>Add Dropbox account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGithubAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"github\"></icon-provider>\n        <span>Add GitHub account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGitlabAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"gitlab\"></icon-provider>\n        <span>Add GitLab account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGoogleDriveAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"googleDrive\"></icon-provider>\n        <span>Add Google Drive account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addWordpressAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"wordpress\"></icon-provider>\n        <span>Add WordPress account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addZendeskAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"zendesk\"></icon-provider>\n        <span>Add Zendesk account</span>\n      </menu-entry>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters } from 'vuex';\nimport MenuEntry from './common/MenuEntry';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport dropboxHelper from '../../services/providers/helpers/dropboxHelper';\nimport githubHelper from '../../services/providers/helpers/githubHelper';\nimport gitlabHelper from '../../services/providers/helpers/gitlabHelper';\nimport wordpressHelper from '../../services/providers/helpers/wordpressHelper';\nimport zendeskHelper from '../../services/providers/helpers/zendeskHelper';\nimport publishSvc from '../../services/publishSvc';\nimport store from '../../store';\n\nconst tokensToArray = (tokens, filter = () => true) => Object.values(tokens)\n  .filter(token => filter(token))\n  .sort((token1, token2) => token1.name.localeCompare(token2.name));\n\nconst publishModalOpener = (type, featureId) => async (token) => {\n  try {\n    const publishLocation = await store.dispatch('modal/open', {\n      type,\n      token,\n    });\n    publishSvc.createPublishLocation(publishLocation, featureId);\n  } catch (e) { /* cancel */ }\n};\n\nexport default {\n  components: {\n    MenuEntry,\n  },\n  computed: {\n    ...mapState('queue', [\n      'isPublishRequested',\n    ]),\n    ...mapGetters('file', [\n      'isCurrentTemp',\n    ]),\n    ...mapGetters('publishLocation', {\n      publishLocations: 'current',\n    }),\n    locationCount() {\n      return Object.keys(this.publishLocations).length;\n    },\n    currentFileName() {\n      return `\"${store.getters['file/current'].name}\"`;\n    },\n    bloggerTokens() {\n      return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isBlogger);\n    },\n    dropboxTokens() {\n      return tokensToArray(store.getters['data/dropboxTokensBySub']);\n    },\n    githubTokens() {\n      return tokensToArray(store.getters['data/githubTokensBySub']);\n    },\n    gitlabTokens() {\n      return tokensToArray(store.getters['data/gitlabTokensBySub']);\n    },\n    googleDriveTokens() {\n      return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isDrive);\n    },\n    wordpressTokens() {\n      return tokensToArray(store.getters['data/wordpressTokensBySub']);\n    },\n    zendeskTokens() {\n      return tokensToArray(store.getters['data/zendeskTokensBySub']);\n    },\n    noToken() {\n      return !this.bloggerTokens.length\n        && !this.dropboxTokens.length\n        && !this.githubTokens.length\n        && !this.gitlabTokens.length\n        && !this.googleDriveTokens.length\n        && !this.wordpressTokens.length\n        && !this.zendeskTokens.length;\n    },\n  },\n  methods: {\n    requestPublish() {\n      if (!this.isPublishRequested) {\n        publishSvc.requestPublish();\n      }\n    },\n    async managePublish() {\n      try {\n        await store.dispatch('modal/open', 'publishManagement');\n      } catch (e) { /* cancel */ }\n    },\n    async addBloggerAccount() {\n      try {\n        await googleHelper.addBloggerAccount();\n      } catch (e) { /* cancel */ }\n    },\n    async addDropboxAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'dropboxAccount' });\n        await dropboxHelper.addAccount(!store.getters['data/localSettings'].dropboxRestrictedAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGithubAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'githubAccount' });\n        await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGitlabAccount() {\n      try {\n        const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });\n        await gitlabHelper.addAccount(serverUrl, applicationId);\n      } catch (e) { /* cancel */ }\n    },\n    async addGoogleDriveAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'googleDriveAccount' });\n        await googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addWordpressAccount() {\n      try {\n        await wordpressHelper.addAccount();\n      } catch (e) { /* cancel */ }\n    },\n    async addZendeskAccount() {\n      try {\n        const { subdomain, clientId } = await store.dispatch('modal/open', { type: 'zendeskAccount' });\n        await zendeskHelper.addAccount(subdomain, clientId);\n      } catch (e) { /* cancel */ }\n    },\n    publishBlogger: publishModalOpener('bloggerPublish', 'publishToBlogger'),\n    publishBloggerPage: publishModalOpener('bloggerPagePublish', 'publishToBloggerPage'),\n    publishDropbox: publishModalOpener('dropboxPublish', 'publishToDropbox'),\n    publishGithub: publishModalOpener('githubPublish', 'publishToGithub'),\n    publishGist: publishModalOpener('gistPublish', 'publishToGist'),\n    publishGitlab: publishModalOpener('gitlabPublish', 'publishToGitlab'),\n    publishGoogleDrive: publishModalOpener('googleDrivePublish', 'publishToGoogleDrive'),\n    publishWordpress: publishModalOpener('wordpressPublish', 'publishToWordPress'),\n    publishZendesk: publishModalOpener('zendeskPublish', 'publishToZendesk'),\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/menus/SyncMenu.vue",
    "content": "<template>\n  <div class=\"side-bar__panel side-bar__panel--menu\">\n    <div class=\"side-bar__info\" v-if=\"isCurrentTemp\">\n      <p>{{currentFileName}} can't be synced as it's a temporary file.</p>\n    </div>\n    <div v-else>\n      <div class=\"side-bar__info\" v-if=\"syncLocations.length\">\n        <p>{{currentFileName}} is already synchronized.</p>\n        <menu-entry @click.native=\"requestSync\">\n          <icon-sync slot=\"icon\"></icon-sync>\n          <div>Synchronize now</div>\n          <span>Download / upload file changes.</span>\n        </menu-entry>\n        <menu-entry @click.native=\"manageSync\">\n          <icon-view-list slot=\"icon\"></icon-view-list>\n          <div><div class=\"menu-entry__label menu-entry__label--count\">{{locationCount}}</div> File synchronization</div>\n          <span>Manage synchronized locations for {{currentFileName}}.</span>\n        </menu-entry>\n      </div>\n      <div class=\"side-bar__info\" v-else-if=\"noToken\">\n        <p>You have to link an account to start syncing files.</p>\n      </div>\n      <hr>\n      <div v-for=\"token in dropboxTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"openDropbox(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"dropbox\"></icon-provider>\n          <div>Open from Dropbox</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"saveDropbox(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"dropbox\"></icon-provider>\n          <div>Save on Dropbox</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in githubTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"openGithub(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"github\"></icon-provider>\n          <div>Open from GitHub</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"saveGithub(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"github\"></icon-provider>\n          <div>Save on GitHub</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"saveGist(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"gist\"></icon-provider>\n          <div>Save on Gist</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in gitlabTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"openGitlab(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"gitlab\"></icon-provider>\n          <div>Open from GitLab</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"saveGitlab(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"gitlab\"></icon-provider>\n          <div>Save on GitLab</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <div v-for=\"token in googleDriveTokens\" :key=\"token.sub\">\n        <menu-entry @click.native=\"openGoogleDrive(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"googleDrive\"></icon-provider>\n          <div>Open from Google Drive</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n        <menu-entry @click.native=\"saveGoogleDrive(token)\">\n          <icon-provider slot=\"icon\" provider-id=\"googleDrive\"></icon-provider>\n          <div>Save on Google Drive</div>\n          <span>{{token.name}}</span>\n        </menu-entry>\n      </div>\n      <hr>\n      <menu-entry @click.native=\"addDropboxAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"dropbox\"></icon-provider>\n        <span>Add Dropbox account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGithubAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"github\"></icon-provider>\n        <span>Add GitHub account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGitlabAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"gitlab\"></icon-provider>\n        <span>Add GitLab account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGoogleDriveAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"googleDrive\"></icon-provider>\n        <span>Add Google Drive account</span>\n      </menu-entry>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapState, mapGetters } from 'vuex';\nimport MenuEntry from './common/MenuEntry';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport dropboxHelper from '../../services/providers/helpers/dropboxHelper';\nimport githubHelper from '../../services/providers/helpers/githubHelper';\nimport gitlabHelper from '../../services/providers/helpers/gitlabHelper';\nimport googleDriveProvider from '../../services/providers/googleDriveProvider';\nimport dropboxProvider from '../../services/providers/dropboxProvider';\nimport githubProvider from '../../services/providers/githubProvider';\nimport gitlabProvider from '../../services/providers/gitlabProvider';\nimport syncSvc from '../../services/syncSvc';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nconst tokensToArray = (tokens, filter = () => true) => Object.values(tokens)\n  .filter(token => filter(token))\n  .sort((token1, token2) => token1.name.localeCompare(token2.name));\n\nconst openSyncModal = (token, type) => store.dispatch('modal/open', {\n  type,\n  token,\n}).then(syncLocation => syncSvc.createSyncLocation(syncLocation));\n\nexport default {\n  components: {\n    MenuEntry,\n  },\n  computed: {\n    ...mapState('queue', [\n      'isSyncRequested',\n    ]),\n    ...mapGetters('workspace', [\n      'syncToken',\n    ]),\n    ...mapGetters('file', [\n      'isCurrentTemp',\n    ]),\n    ...mapGetters('syncLocation', {\n      syncLocations: 'currentWithWorkspaceSyncLocation',\n    }),\n    locationCount() {\n      return Object.keys(this.syncLocations).length;\n    },\n    currentFileName() {\n      return `\"${store.getters['file/current'].name}\"`;\n    },\n    dropboxTokens() {\n      return tokensToArray(store.getters['data/dropboxTokensBySub']);\n    },\n    githubTokens() {\n      return tokensToArray(store.getters['data/githubTokensBySub']);\n    },\n    gitlabTokens() {\n      return tokensToArray(store.getters['data/gitlabTokensBySub']);\n    },\n    googleDriveTokens() {\n      return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isDrive);\n    },\n    noToken() {\n      return !this.googleDriveTokens.length\n        && !this.dropboxTokens.length\n        && !this.githubTokens.length;\n    },\n  },\n  methods: {\n    requestSync() {\n      if (!this.isSyncRequested) {\n        syncSvc.requestSync(true);\n      }\n    },\n    async manageSync() {\n      try {\n        await store.dispatch('modal/open', 'syncManagement');\n      } catch (e) { /* cancel */ }\n    },\n    async addDropboxAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'dropboxAccount' });\n        await dropboxHelper.addAccount(!store.getters['data/localSettings'].dropboxRestrictedAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGithubAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'githubAccount' });\n        await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGitlabAccount() {\n      try {\n        const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });\n        await gitlabHelper.addAccount(serverUrl, applicationId);\n      } catch (e) { /* cancel */ }\n    },\n    async addGoogleDriveAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'googleDriveAccount' });\n        await googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async openDropbox(token) {\n      const paths = await dropboxHelper.openChooser(token);\n      store.dispatch(\n        'queue/enqueue',\n        async () => {\n          await dropboxProvider.openFiles(token, paths);\n          badgeSvc.addBadge('openFromDropbox');\n        },\n      );\n    },\n    async saveDropbox(token) {\n      try {\n        await openSyncModal(token, 'dropboxSave');\n        badgeSvc.addBadge('saveOnDropbox');\n      } catch (e) { /* cancel */ }\n    },\n    async openGoogleDrive(token) {\n      const files = await googleHelper.openPicker(token, 'doc');\n      store.dispatch(\n        'queue/enqueue',\n        async () => {\n          await googleDriveProvider.openFiles(token, files);\n          badgeSvc.addBadge('openFromGoogleDrive');\n        },\n      );\n    },\n    async saveGoogleDrive(token) {\n      try {\n        await openSyncModal(token, 'googleDriveSave');\n        badgeSvc.addBadge('saveOnGoogleDrive');\n      } catch (e) { /* cancel */ }\n    },\n    async openGithub(token) {\n      try {\n        const syncLocation = await store.dispatch('modal/open', {\n          type: 'githubOpen',\n          token,\n        });\n        store.dispatch(\n          'queue/enqueue',\n          async () => {\n            await githubProvider.openFile(token, syncLocation);\n            badgeSvc.addBadge('openFromGithub');\n          },\n        );\n      } catch (e) { /* cancel */ }\n    },\n    async saveGithub(token) {\n      try {\n        await openSyncModal(token, 'githubSave');\n        badgeSvc.addBadge('saveOnGithub');\n      } catch (e) { /* cancel */ }\n    },\n    async saveGist(token) {\n      try {\n        await openSyncModal(token, 'gistSync');\n        badgeSvc.addBadge('saveOnGist');\n      } catch (e) { /* cancel */ }\n    },\n    async openGitlab(token) {\n      try {\n        const syncLocation = await store.dispatch('modal/open', {\n          type: 'gitlabOpen',\n          token,\n        });\n        store.dispatch(\n          'queue/enqueue',\n          async () => {\n            await gitlabProvider.openFile(token, syncLocation);\n            badgeSvc.addBadge('openFromGitlab');\n          },\n        );\n      } catch (e) { /* cancel */ }\n    },\n    async saveGitlab(token) {\n      try {\n        await openSyncModal(token, 'gitlabSave');\n        badgeSvc.addBadge('saveOnGitlab');\n      } catch (e) { /* cancel */ }\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/menus/WorkspaceBackupMenu.vue",
    "content": "<template>\n  <div class=\"side-bar__panel side-bar__panel--menu\">\n    <input class=\"hidden-file\" id=\"import-backup-file-input\" type=\"file\" @change=\"onImportBackup\">\n    <label class=\"menu-entry button flex flex--row flex--align-center\" for=\"import-backup-file-input\">\n      <div class=\"menu-entry__icon flex flex--column flex--center\">\n        <icon-content-save></icon-content-save>\n      </div>\n      <div class=\"flex flex--column\">\n        Import workspace backup\n      </div>\n    </label>\n    <menu-entry @click.native=\"exportWorkspace\">\n      <icon-content-save slot=\"icon\"></icon-content-save>\n      Export workspace backup\n    </menu-entry>\n  </div>\n</template>\n\n<script>\nimport FileSaver from 'file-saver';\nimport MenuEntry from './common/MenuEntry';\nimport store from '../../store';\nimport backupSvc from '../../services/backupSvc';\nimport localDbSvc from '../../services/localDbSvc';\n\nexport default {\n  components: {\n    MenuEntry,\n  },\n  computed: {\n    workspaceId: () => store.getters['workspace/currentWorkspace'].id,\n  },\n  methods: {\n    onImportBackup(evt) {\n      const file = evt.target.files[0];\n      if (file) {\n        const reader = new FileReader();\n        reader.onload = (e) => {\n          const text = e.target.result;\n          if (text.match(/\\uFFFD/)) {\n            store.dispatch('notification/error', 'File is not readable.');\n          } else {\n            backupSvc.importBackup(text);\n          }\n        };\n        const blob = file.slice(0, 10000000);\n        reader.readAsText(blob);\n      }\n    },\n    exportWorkspace() {\n      const allItemsById = {};\n      localDbSvc.getWorkspaceItems(this.workspaceId, (item) => {\n        allItemsById[item.id] = item;\n      }, () => {\n        const backup = JSON.stringify(allItemsById);\n        const blob = new Blob([backup], {\n          type: 'text/plain;charset=utf-8',\n        });\n        FileSaver.saveAs(blob, 'StackEdit workspace.json');\n      });\n    },\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/menus/WorkspacesMenu.vue",
    "content": "<template>\n  <div class=\"side-bar__panel side-bar__panel--menu\">\n    <menu-entry @click.native=\"manageWorkspaces\">\n      <icon-database slot=\"icon\"></icon-database>\n      <div><div class=\"menu-entry__label menu-entry__label--count\">{{workspaceCount}}</div> Manage workspaces</div>\n      <span>List, rename, remove workspaces</span>\n    </menu-entry>\n    <hr>\n    <div class=\"workspace\" v-for=\"(workspace, id) in workspacesById\" :key=\"id\">\n      <menu-entry :href=\"workspace.url\" target=\"_blank\">\n        <icon-provider slot=\"icon\" :provider-id=\"workspace.providerId\"></icon-provider>\n        <div class=\"workspace__name\"><div class=\"menu-entry__label\" v-if=\"currentWorkspace === workspace\">current</div>{{workspace.name}}</div>\n      </menu-entry>\n    </div>\n    <hr>\n    <menu-entry @click.native=\"addCouchdbWorkspace\">\n      <icon-provider slot=\"icon\" provider-id=\"couchdbWorkspace\"></icon-provider>\n      <span>Add a <b>CouchDB</b> workspace</span>\n    </menu-entry>\n    <menu-entry @click.native=\"addGithubWorkspace\">\n      <icon-provider slot=\"icon\" provider-id=\"githubWorkspace\"></icon-provider>\n      <span>Add a <b>GitHub</b> workspace</span>\n    </menu-entry>\n    <menu-entry @click.native=\"addGitlabWorkspace\">\n      <icon-provider slot=\"icon\" provider-id=\"gitlabWorkspace\"></icon-provider>\n      <span>Add a <b>GitLab</b> workspace</span>\n    </menu-entry>\n    <menu-entry @click.native=\"addGoogleDriveWorkspace\">\n      <icon-provider slot=\"icon\" provider-id=\"googleDriveWorkspace\"></icon-provider>\n      <span>Add a <b>Google Drive</b> workspace</span>\n    </menu-entry>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport MenuEntry from './common/MenuEntry';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport gitlabHelper from '../../services/providers/helpers/gitlabHelper';\nimport store from '../../store';\n\nexport default {\n  components: {\n    MenuEntry,\n  },\n  computed: {\n    ...mapGetters('workspace', [\n      'workspacesById',\n      'currentWorkspace',\n    ]),\n    workspaceCount() {\n      return Object.keys(this.workspacesById).length;\n    },\n  },\n  methods: {\n    async addCouchdbWorkspace() {\n      try {\n        store.dispatch('modal/open', {\n          type: 'couchdbWorkspace',\n        });\n      } catch (e) { /* Cancel */ }\n    },\n    async addGithubWorkspace() {\n      try {\n        store.dispatch('modal/open', {\n          type: 'githubWorkspace',\n        });\n      } catch (e) { /* Cancel */ }\n    },\n    async addGitlabWorkspace() {\n      try {\n        const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });\n        const token = await gitlabHelper.addAccount(serverUrl, applicationId);\n        store.dispatch('modal/open', {\n          type: 'gitlabWorkspace',\n          token,\n        });\n      } catch (e) { /* Cancel */ }\n    },\n    async addGoogleDriveWorkspace() {\n      try {\n        const token = await googleHelper.addDriveAccount(true);\n        store.dispatch('modal/open', {\n          type: 'googleDriveWorkspace',\n          token,\n        });\n      } catch (e) { /* Cancel */ }\n    },\n    manageWorkspaces() {\n      try {\n        store.dispatch('modal/open', 'workspaceManagement');\n      } catch (e) { /* Cancel */ }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.workspace .menu-entry {\n  padding-top: 12px;\n  padding-bottom: 12px;\n}\n\n.workspace__name {\n  font-weight: bold;\n  line-height: 1.2;\n}\n</style>\n"
  },
  {
    "path": "src/components/menus/common/MenuEntry.vue",
    "content": "<template>\n  <a class=\"menu-entry button flex flex--row flex--align-center\" href=\"javascript:void(0)\">\n    <div class=\"menu-entry__icon flex flex--column flex--center\">\n      <slot name=\"icon\"></slot>\n    </div>\n    <div class=\"menu-entry__text flex flex--column\">\n      <slot></slot>\n    </div>\n  </a>\n</template>\n\n<style lang=\"scss\">\n@import '../../../styles/variables.scss';\n\n.menu-entry {\n  text-align: left;\n  padding: 10px;\n  height: auto;\n  font-size: 17px;\n  line-height: 1.4;\n  text-transform: none;\n  white-space: normal;\n\n  span {\n    display: inline-block;\n    font-size: 0.75rem;\n    opacity: 0.67;\n    line-height: 1.3;\n\n    .menu-entry__label {\n      opacity: 1;\n    }\n\n    span {\n      display: inline;\n      opacity: 1;\n    }\n  }\n}\n\n.menu-entry--info {\n  padding-top: 3px;\n  padding-bottom: 3px;\n}\n\n.menu-entry__icon {\n  height: 20px;\n  width: 20px;\n  margin-right: 12px;\n  flex: none;\n}\n\n.menu-entry__icon--disabled {\n  opacity: 0.5;\n}\n\n.menu-entry__icon--image {\n  border-radius: $border-radius-base;\n  overflow: hidden;\n}\n\n.hidden-file {\n  position: fixed;\n  top: -999px;\n}\n\n.menu-entry__label {\n  float: right;\n  font-size: 0.6rem;\n  font-weight: 600;\n  line-height: 1;\n  padding: 0.15em 0.25em;\n  background-color: #fff;\n  border-radius: 3px;\n  opacity: 0.6;\n}\n\n.menu-entry__label--warning {\n  color: #fff;\n  background-color: darken($error-color, 10);\n  opacity: 1;\n}\n\n.menu-entry__label--count {\n  font-size: 0.75rem;\n  font-weight: 400;\n}\n\n.menu-entry__text {\n  width: 100%;\n  overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/AboutModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--about-modal\" aria-label=\"About\">\n    <div class=\"modal__content\">\n      <div class=\"logo-background\"></div>\n      StackEdit on <a target=\"_blank\" href=\"https://github.com/benweet/stackedit/\">GitHub</a>\n      <br>\n      <a target=\"_blank\" href=\"https://github.com/benweet/stackedit/issues\">Issue tracker</a> — <a target=\"_blank\" href=\"https://github.com/benweet/stackedit/releases\">Changelog</a>\n      <br>\n      <a target=\"_blank\" href=\"https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg\">Chrome app</a> — <a target=\"_blank\" href=\"https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha\">Chrome extension</a>\n      <br>\n      <a target=\"_blank\" href=\"https://community.stackedit.io/\">Community</a> — <a target=\"_blank\" href=\"https://community.stackedit.io/c/how-to\">Tutos and How To</a>\n      <br>\n      StackEdit on <a target=\"_blank\" href=\"https://twitter.com/stackedit/\">Twitter</a>\n      <hr>\n      <small>© 2013-2019 Dock5 Software Ltd.<br>v{{version}}</small>\n      <h3>FAQ</h3>\n      <div class=\"faq\" v-html=\"faq\"></div>\n      <div class=\"modal__info\">\n        For commercial support or custom development, please <a href=\"mailto:stackedit.project@gmail.com\">contact us</a>.\n      </div>\n      Licensed under an\n      <a target=\"_blank\" href=\"http://www.apache.org/licenses/LICENSE-2.0\">Apache License</a><br>\n      <a target=\"_blank\" href=\"privacy_policy.html\">Privacy Policy</a>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Close</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport markdownConversionSvc from '../../services/markdownConversionSvc';\nimport faq from '../../data/faq.md';\n\nexport default {\n  components: {\n    ModalInner,\n  },\n  data: () => ({\n    version: VERSION,\n  }),\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    faq() {\n      return markdownConversionSvc.defaultConverter.render(faq);\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.modal__inner-1--about-modal {\n  text-align: center;\n\n  .logo-background {\n    height: 75px;\n    margin: 0.5em 0;\n  }\n\n  small {\n    display: block;\n  }\n\n  hr {\n    width: 160px;\n    max-width: 100%;\n    margin: 1.5em auto;\n  }\n}\n\n.faq {\n  font-size: 0.8em;\n  line-height: 1.5;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/AccountManagementModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--account-management\" aria-label=\"Manage external accounts\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-key></icon-key>\n      </div>\n      <p v-if=\"entries.length\">StackEdit has access to the following external accounts:</p>\n      <p v-else>StackEdit has no access to any external account yet.</p>\n      <div>\n        <div class=\"account-entry flex flex--column\" v-for=\"entry in entries\" :key=\"entry.token.sub\">\n          <div class=\"account-entry__header flex flex--row flex--align-center\">\n            <div class=\"account-entry__icon flex flex--column flex--center\">\n              <icon-provider :provider-id=\"entry.providerId\"></icon-provider>\n            </div>\n            <div class=\"account-entry__description\">\n              {{entry.name}}\n            </div>\n            <div class=\"account-entry__buttons flex flex--row flex--center\">\n              <button class=\"account-entry__button button\" @click=\"remove(entry)\" v-title=\"'Remove access'\">\n                <icon-delete></icon-delete>\n              </button>\n            </div>\n          </div>\n          <div class=\"account-entry__row\">\n            <span class=\"account-entry__field\" v-if=\"entry.userId\">\n              <b>User ID:</b>\n              {{entry.userId}}\n            </span>\n            <span class=\"account-entry__field\" v-if=\"entry.url\">\n              <b>URL:</b>\n              {{entry.url}}\n            </span>\n            <span class=\"account-entry__field\" v-if=\"entry.scopes\">\n              <b>Scopes:</b>\n              {{entry.scopes.join(', ')}}\n            </span>\n          </div>\n        </div>\n      </div>\n      <menu-entry @click.native=\"addBloggerAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"blogger\"></icon-provider>\n        <span>Add Blogger account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addDropboxAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"dropbox\"></icon-provider>\n        <span>Add Dropbox account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGithubAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"github\"></icon-provider>\n        <span>Add GitHub account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGitlabAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"gitlab\"></icon-provider>\n        <span>Add GitLab account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGoogleDriveAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"googleDrive\"></icon-provider>\n        <span>Add Google Drive account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGooglePhotosAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"googlePhotos\"></icon-provider>\n        <span>Add Google Photos account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addWordpressAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"wordpress\"></icon-provider>\n        <span>Add WordPress account</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addZendeskAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"zendesk\"></icon-provider>\n        <span>Add Zendesk account</span>\n      </menu-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Close</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport MenuEntry from '../menus/common/MenuEntry';\nimport store from '../../store';\nimport utils from '../../services/utils';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport dropboxHelper from '../../services/providers/helpers/dropboxHelper';\nimport githubHelper from '../../services/providers/helpers/githubHelper';\nimport gitlabHelper from '../../services/providers/helpers/gitlabHelper';\nimport wordpressHelper from '../../services/providers/helpers/wordpressHelper';\nimport zendeskHelper from '../../services/providers/helpers/zendeskHelper';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default {\n  components: {\n    ModalInner,\n    MenuEntry,\n  },\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    entries() {\n      return [\n        ...Object.values(store.getters['data/googleTokensBySub']).map(token => ({\n          token,\n          providerId: 'google',\n          userId: token.sub,\n          name: token.name,\n          scopes: ['openid', 'profile', ...token.scopes\n            .map(scope => scope.replace(/^https:\\/\\/www.googleapis.com\\/auth\\//, ''))],\n        })),\n        ...Object.values(store.getters['data/couchdbTokensBySub']).map(token => ({\n          token,\n          providerId: 'couchdb',\n          url: token.dbUrl,\n          name: token.name,\n        })),\n        ...Object.values(store.getters['data/dropboxTokensBySub']).map(token => ({\n          token,\n          providerId: 'dropbox',\n          userId: token.sub,\n          name: token.name,\n        })),\n        ...Object.values(store.getters['data/githubTokensBySub']).map(token => ({\n          token,\n          providerId: 'github',\n          userId: token.sub,\n          name: token.name,\n          scopes: token.scopes,\n        })),\n        ...Object.values(store.getters['data/gitlabTokensBySub']).map(token => ({\n          token,\n          providerId: 'gitlab',\n          url: token.serverUrl,\n          userId: token.sub,\n          name: token.name,\n          scopes: ['api'],\n        })),\n        ...Object.values(store.getters['data/wordpressTokensBySub']).map(token => ({\n          token,\n          providerId: 'wordpress',\n          userId: token.sub,\n          name: token.name,\n          scopes: ['global'],\n        })),\n        ...Object.values(store.getters['data/zendeskTokensBySub']).map(token => ({\n          token,\n          providerId: 'zendesk',\n          url: `https://${token.subdomain}.zendesk.com/`,\n          userId: token.sub,\n          name: token.name,\n          scopes: ['read', 'hc:write'],\n        })),\n      ];\n    },\n  },\n  methods: {\n    async remove(entry) {\n      const tokensBySub = utils.deepCopy(store.getters[`data/${entry.providerId}TokensBySub`]);\n      delete tokensBySub[entry.token.sub];\n      await store.dispatch('data/patchTokensByType', {\n        [entry.providerId]: tokensBySub,\n      });\n      badgeSvc.addBadge('removeAccount');\n    },\n    async addBloggerAccount() {\n      try {\n        await googleHelper.addBloggerAccount();\n      } catch (e) { /* cancel */ }\n    },\n    async addDropboxAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'dropboxAccount' });\n        await dropboxHelper.addAccount(!store.getters['data/localSettings'].dropboxRestrictedAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGithubAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'githubAccount' });\n        await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGitlabAccount() {\n      try {\n        const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });\n        await gitlabHelper.addAccount(serverUrl, applicationId);\n      } catch (e) { /* cancel */ }\n    },\n    async addGoogleDriveAccount() {\n      try {\n        await store.dispatch('modal/open', { type: 'googleDriveAccount' });\n        await googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess);\n      } catch (e) { /* cancel */ }\n    },\n    async addGooglePhotosAccount() {\n      try {\n        await googleHelper.addPhotosAccount();\n      } catch (e) { /* cancel */ }\n    },\n    async addWordpressAccount() {\n      try {\n        await wordpressHelper.addAccount();\n      } catch (e) { /* cancel */ }\n    },\n    async addZendeskAccount() {\n      try {\n        const { subdomain, clientId } = await store.dispatch('modal/open', { type: 'zendeskAccount' });\n        await zendeskHelper.addAccount(subdomain, clientId);\n      } catch (e) { /* cancel */ }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.account-entry {\n  margin: 1.5em 0;\n  height: auto;\n  font-size: 17px;\n  line-height: 1.5;\n}\n\n$button-size: 30px;\n\n.account-entry__header {\n  line-height: $button-size;\n}\n\n.account-entry__row {\n  border-top: 1px solid $hr-color;\n  font-size: 0.67em;\n  padding: 0.25em 0;\n}\n\n.account-entry__field {\n  opacity: 0.5;\n}\n\n.account-entry__icon {\n  height: 22px;\n  width: 22px;\n  margin-right: 0.75rem;\n  flex: none;\n}\n\n.account-entry__description {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n.account-entry__buttons {\n  margin-left: 0.75rem;\n}\n\n.account-entry__button {\n  width: $button-size;\n  height: $button-size;\n  padding: 4px;\n  background-color: transparent;\n  opacity: 0.75;\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/BadgeManagementModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--badge-management\" aria-label=\"Manage badges\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-seal></icon-seal>\n      </div>\n      <p v-if=\"badgeCount > 1\">{{badgeCount}} badges earned</p>\n      <p v-else>{{badgeCount}} badge earned</p>\n      <div class=\"badge-entry\" v-for=\"badge in badgeTree\" :key=\"badge.featureId\">\n        <div class=\"flex flex--row\">\n          <icon-seal class=\"badge-entry__icon\" :class=\"{'badge-entry__icon--earned': badge.isEarned, 'badge-entry__icon--some-earned': badge.hasSomeEarned}\"></icon-seal>\n          <div>\n            <span class=\"badge-entry__name\" :class=\"{'badge-entry__name--earned': badge.isEarned, 'badge-entry__name--some-earned': badge.hasSomeEarned}\">{{badge.name}}</span>\n            <span class=\"badge-entry__description\">&mdash; {{badge.description}}</span>\n            <a href=\"javascript:void(0)\" v-if=\"!shown[badge.featureId]\" @click=\"show(badge.featureId)\">Show</a>\n            <div class=\"badge-entry\" v-else v-for=\"child in badge.children\" :key=\"child.featureId\">\n              <div class=\"flex flex--row\">\n                <icon-seal class=\"badge-entry__icon\" :class=\"{'badge-entry__icon--earned': child.isEarned}\"></icon-seal>\n                <div>\n                  <span class=\"badge-entry__name\" :class=\"{'badge-entry__name--earned': child.isEarned}\">{{child.name}}</span>\n                  <span class=\"badge-entry__description\">&mdash; {{child.description}}</span>\n                </div>\n            </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Close</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport Vue from 'vue';\nimport { mapGetters } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport store from '../../store';\n\nexport default {\n  components: {\n    ModalInner,\n  },\n  data: () => ({\n    shown: {},\n  }),\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    ...mapGetters('data', [\n      'badgeTree',\n    ]),\n    badgeCount() {\n      return store.getters['data/allBadges'].filter(badge => badge.isEarned).length;\n    },\n    featureCount() {\n      return store.getters['data/allBadges'].length;\n    },\n  },\n  methods: {\n    show(featureId) {\n      Vue.set(this.shown, featureId, true);\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.modal__inner-1.modal__inner-1--badge-management {\n  max-width: 520px;\n\n  p {\n    font-size: 1.8rem;\n    font-weight: bold;\n  }\n}\n\n.badge-entry {\n  line-height: 1.4;\n  margin: 2rem 0;\n  font-size: 0.9em;\n\n  .badge-entry {\n    font-size: 0.8em;\n    margin: 0.75rem 0;\n  }\n}\n\n.badge-entry__icon {\n  width: 1.67em;\n  height: 1.67em;\n  margin-right: 0.25em;\n  opacity: 0.3;\n  flex: none;\n}\n\n.badge-entry__icon--some-earned {\n  opacity: 0.5;\n  color: goldenrod;\n}\n\n.badge-entry__icon--earned {\n  opacity: 1;\n  color: goldenrod;\n}\n\n.badge-entry__description {\n  opacity: 0.6;\n}\n\n.badge-entry__name {\n  font-size: 1.2em;\n  font-weight: bold;\n  opacity: 0.4;\n}\n\n.badge-entry__name--earned {\n  opacity: 1;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/FilePropertiesModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--file-properties\" aria-label=\"File properties\">\n    <div class=\"modal__content\">\n      <div class=\"tabs flex flex--row\">\n        <tab :active=\"tab === 'simple'\" @click=\"setSimpleTab()\">\n          Simple properties\n        </tab>\n        <tab :active=\"tab === 'yaml'\" @click=\"setYamlTab()\">\n          YAML properties\n        </tab>\n      </div>\n      <div v-if=\"tab === 'simple'\">\n        <div class=\"modal__title\">Extensions</div>\n        <div class=\"modal__sub-title\">Configure the Markdown engine.</div>\n        <form-entry label=\"Preset\">\n          <select slot=\"field\" class=\"textfield\" v-model=\"preset\" @keydown.enter=\"resolve()\">\n            <option v-for=\"(preset, id) in presets\" :key=\"id\" :value=\"preset\">\n              {{ preset }}\n            </option>\n          </select>\n        </form-entry>\n        <div class=\"modal__title\">Metadata</div>\n        <div class=\"modal__sub-title\">Add info to your publications (Wordpress, Blogger...).</div>\n        <form-entry label=\"Title\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"title\" @keydown.enter=\"resolve()\">\n        </form-entry>\n        <form-entry label=\"Author\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"author\" @keydown.enter=\"resolve()\">\n        </form-entry>\n        <form-entry label=\"Tags\" info=\"comma-separated\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"tags\" @keydown.enter=\"resolve()\">\n        </form-entry>\n        <form-entry label=\"Categories\" info=\"comma-separated\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"categories\" @keydown.enter=\"resolve()\">\n        </form-entry>\n        <form-entry label=\"Excerpt\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"excerpt\" @keydown.enter=\"resolve()\">\n        </form-entry>\n        <form-entry label=\"Featured image\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"featuredImage\" @keydown.enter=\"resolve()\">\n        </form-entry>\n        <form-entry label=\"Status\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"status\" @keydown.enter=\"resolve()\">\n          <div class=\"form-entry__info\">\n            <b>Example:</b> draft\n          </div>\n        </form-entry>\n        <form-entry label=\"Date\" info=\"YYYY-MM-DD\">\n          <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"date\" @keydown.enter=\"resolve()\">\n        </form-entry>\n      </div>\n      <div v-if=\"tab === 'yaml'\">\n        <div class=\"form-entry\" role=\"tabpanel\" aria-label=\"YAML properties\">\n          <label class=\"form-entry__label\">YAML</label>\n          <div class=\"form-entry__field\">\n            <code-editor lang=\"yaml\" :value=\"yamlProperties\" key=\"custom-properties\" @changed=\"setYamlProperties\"></code-editor>\n          </div>\n        </div>\n        <div class=\"modal__error modal__error--file-properties\">{{error}}</div>\n        <div class=\"modal__info modal__info--multiline\">\n          <p><strong>ProTip:</strong> You can manually toggle extensions:</p>\n          <pre class=\" language-yaml\"><code class=\"prism  language-yaml\"><span class=\"token key atrule\">extensions</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">emoji</span><span class=\"token punctuation\">:</span>\n    <span class=\"token comment\"># Enable emoji shortcuts like :) :-(</span>\n    <span class=\"token key atrule\">shortcuts</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span>\n</code></pre>\n          <p>Use preset <code>zero</code> to make your own configuration:</p>\n          <pre class=\" language-yaml\"><code class=\"prism  language-yaml\"><span class=\"token key atrule\">extensions</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">preset</span><span class=\"token punctuation\">:</span> zero\n  <span class=\"token key atrule\">markdown</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">table</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span>\n  <span class=\"token key atrule\">katex</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">enabled</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span>\n</code></pre>\n          <p>For the full list of options, see <a href=\"https://github.com/benweet/stackedit/blob/master/src/data/presets.js\" target=\"_blank\">here</a>.</p>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport yaml from 'js-yaml';\nimport { mapGetters } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport Tab from './common/Tab';\nimport FormEntry from './common/FormEntry';\nimport CodeEditor from '../CodeEditor';\nimport utils from '../../services/utils';\nimport presets from '../../data/presets';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nconst metadataProperties = {\n  title: '',\n  author: '',\n  tags: '',\n  categories: '',\n  excerpt: '',\n  featuredImage: '',\n  status: '',\n  date: '',\n};\n\nexport default {\n  components: {\n    ModalInner,\n    Tab,\n    FormEntry,\n    CodeEditor,\n  },\n  data: () => ({\n    contentId: null,\n    yamlProperties: null,\n    preset: '',\n    error: null,\n    ...metadataProperties,\n  }),\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    presets: () => Object.keys(presets).sort(),\n    tab: {\n      get() {\n        return store.getters['data/localSettings'].filePropertiesTab;\n      },\n      set(value) {\n        store.dispatch('data/patchLocalSettings', {\n          filePropertiesTab: value,\n        });\n      },\n    },\n  },\n  created() {\n    const content = store.getters['content/current'];\n    this.contentId = content.id;\n    this.setYamlProperties(content.properties);\n    if (this.tab !== 'yaml') {\n      this.setSimpleTab();\n    }\n  },\n  methods: {\n    yamlToSimple() {\n      const properties = this.properties || {};\n      const extensions = properties.extensions || {};\n      this.preset = extensions.preset;\n      if (!this.presets.includes(this.preset)) {\n        this.preset = 'default';\n      }\n      Object.keys(metadataProperties).forEach((name) => {\n        this[name] = `${properties[name] || ''}`;\n      });\n    },\n    simpleToYaml() {\n      let hasChanged = false;\n      const properties = this.properties || {};\n      const extensions = properties.extensions || {};\n      if (this.preset !== extensions.preset) {\n        if (this.preset !== 'default') {\n          extensions.preset = this.preset;\n          hasChanged = true;\n        } else if (extensions.preset) {\n          delete extensions.preset;\n          hasChanged = true;\n        }\n      }\n      Object.keys(metadataProperties).forEach((name) => {\n        if (this[name] !== properties[name]) {\n          if (this[name]) {\n            properties[name] = this[name];\n            hasChanged = true;\n          } else if (properties[name]) {\n            delete properties[name];\n            hasChanged = true;\n          }\n        }\n      });\n      if (hasChanged) {\n        if (Object.keys(extensions).length) {\n          properties.extensions = extensions;\n        } else {\n          delete properties.extensions;\n        }\n        this.setYamlProperties(Object.keys(properties).length\n          ? yaml.safeDump(properties)\n          : '\\n');\n      }\n    },\n    setSimpleTab() {\n      this.tab = 'simple';\n      this.yamlToSimple();\n    },\n    setYamlTab() {\n      this.tab = 'yaml';\n      this.simpleToYaml();\n    },\n    setYamlProperties(value) {\n      this.yamlProperties = value;\n      try {\n        this.properties = yaml.safeLoad(value);\n        this.error = null;\n      } catch (e) {\n        this.error = e.message;\n      }\n    },\n    resolve() {\n      if (this.tab === 'simple') {\n        // Compute YAML properties\n        this.simpleToYaml();\n      }\n      if (this.error) {\n        this.setYamlTab();\n      } else {\n        const properties = this.properties || {};\n        if (Object.keys(metadataProperties).some(key => properties[key])) {\n          badgeSvc.addBadge('setMetadata');\n        }\n        const extensions = properties.extensions || {};\n        if (extensions.preset) {\n          badgeSvc.addBadge('changePreset');\n        }\n        if (Object.keys(extensions).filter(key => key !== 'preset').length) {\n          badgeSvc.addBadge('changeExtension');\n        }\n        store.commit('content/patchItem', {\n          id: this.contentId,\n          properties: utils.sanitizeText(this.yamlProperties),\n        });\n        this.config.resolve();\n      }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.modal__inner-1.modal__inner-1--file-properties {\n  max-width: 520px;\n}\n\n.modal__error--file-properties {\n  white-space: pre-wrap;\n  font-family: $font-family-monospace;\n  font-size: $font-size-monospace;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/HtmlExportModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Export to HTML\">\n    <div class=\"modal__content\">\n      <p>Please choose a template for your <b>HTML export</b>.</p>\n      <form-entry label=\"Template\">\n        <select class=\"textfield\" slot=\"field\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--copy\" v-clipboard=\"result\" @click=\"info('HTML copied to clipboard!')\">Copy</button>\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapActions } from 'vuex';\nimport exportSvc from '../../services/exportSvc';\nimport modalTemplate from './common/modalTemplate';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default modalTemplate({\n  data: () => ({\n    result: '',\n  }),\n  computedLocalSettings: {\n    selectedTemplate: 'htmlExportTemplate',\n  },\n  mounted() {\n    let timeoutId;\n    this.$watch('selectedTemplate', (selectedTemplate) => {\n      clearTimeout(timeoutId);\n      timeoutId = setTimeout(async () => {\n        const currentFile = store.getters['file/current'];\n        const html = await exportSvc.applyTemplate(\n          currentFile.id,\n          this.allTemplatesById[selectedTemplate],\n        );\n        this.result = html;\n      }, 10);\n    }, {\n      immediate: true,\n    });\n  },\n  methods: {\n    ...mapActions('notification', [\n      'info',\n    ]),\n    async resolve() {\n      const { config } = this;\n      const currentFile = store.getters['file/current'];\n      config.resolve();\n      await exportSvc.exportToDisk(currentFile.id, 'html', this.allTemplatesById[this.selectedTemplate]);\n      badgeSvc.addBadge('exportHtml');\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/ImageModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Insert image\">\n    <div class=\"modal__content\">\n      <p>Please provide a <b>URL</b> for your image.</p>\n      <form-entry label=\"URL\" error=\"url\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"url\" @keydown.enter=\"resolve\">\n      </form-entry>\n      <menu-entry @click.native=\"openGooglePhotos(token)\" v-for=\"token in googlePhotosTokens\" :key=\"token.sub\">\n        <icon-provider slot=\"icon\" provider-id=\"googlePhotos\"></icon-provider>\n        <div>Open from Google Photos</div>\n        <span>{{token.name}}</span>\n      </menu-entry>\n      <menu-entry @click.native=\"addGooglePhotosAccount\">\n        <icon-provider slot=\"icon\" provider-id=\"googlePhotos\"></icon-provider>\n        <span>Add Google Photos account</span>\n      </menu-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from './common/modalTemplate';\nimport MenuEntry from '../menus/common/MenuEntry';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport store from '../../store';\n\nexport default modalTemplate({\n  components: {\n    MenuEntry,\n  },\n  data: () => ({\n    url: '',\n  }),\n  computed: {\n    googlePhotosTokens() {\n      const googleTokensBySub = store.getters['data/googleTokensBySub'];\n      return Object.values(googleTokensBySub)\n        .filter(token => token.isPhotos)\n        .sort((token1, token2) => token1.name.localeCompare(token2.name));\n    },\n  },\n  methods: {\n    resolve(evt) {\n      evt.preventDefault(); // Fixes https://github.com/benweet/stackedit/issues/1503\n      if (!this.url) {\n        this.setError('url');\n      } else {\n        const { callback } = this.config;\n        this.config.resolve();\n        callback(this.url);\n      }\n    },\n    reject() {\n      const { callback } = this.config;\n      this.config.reject();\n      callback(null);\n    },\n    async addGooglePhotosAccount() {\n      try {\n        await googleHelper.addPhotosAccount();\n      } catch (e) { /* cancel */ }\n    },\n    async openGooglePhotos(token) {\n      const { callback } = this.config;\n      this.config.reject();\n      const res = await googleHelper.openPicker(token, 'img');\n      if (res[0]) {\n        store.dispatch('modal/open', {\n          type: 'googlePhoto',\n          url: res[0].url,\n          callback,\n        });\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/LinkModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Insert link\">\n    <div class=\"modal__content\">\n      <p>Please provide a <b>URL</b> for your link.</p>\n      <form-entry label=\"URL\" error=\"url\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"url\" @keydown.enter=\"resolve\">\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from './common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    url: '',\n  }),\n  methods: {\n    resolve(evt) {\n      evt.preventDefault(); // Fixes https://github.com/benweet/stackedit/issues/1503\n      if (!this.url) {\n        this.setError('url');\n      } else {\n        const { callback } = this.config;\n        this.config.resolve();\n        callback(this.url);\n      }\n    },\n    reject() {\n      const { callback } = this.config;\n      this.config.reject();\n      callback(null);\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/PandocExportModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Export with Pandoc\">\n    <div class=\"modal__content\">\n      <p>Please choose a format for your <b>Pandoc export</b>.</p>\n      <form-entry label=\"Template\">\n        <select class=\"textfield\" slot=\"field\" v-model=\"selectedFormat\" @keydown.enter=\"resolve()\">\n          <option value=\"asciidoc\">AsciiDoc</option>\n          <option value=\"context\">ConTeXt</option>\n          <option value=\"epub\">EPUB</option>\n          <option value=\"epub3\">EPUB v3</option>\n          <option value=\"latex\">LaTeX</option>\n          <option value=\"odt\">OpenOffice</option>\n          <option value=\"pdf\">PDF</option>\n          <option value=\"rst\">reStructuredText</option>\n          <option value=\"rtf\">Rich Text Format</option>\n          <option value=\"textile\">Textile</option>\n          <option value=\"docx\">Word</option>\n        </select>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport FileSaver from 'file-saver';\nimport networkSvc from '../../services/networkSvc';\nimport editorSvc from '../../services/editorSvc';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport modalTemplate from './common/modalTemplate';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default modalTemplate({\n  computedLocalSettings: {\n    selectedFormat: 'pandocExportFormat',\n  },\n  methods: {\n    async resolve() {\n      this.config.resolve();\n      const currentFile = store.getters['file/current'];\n      const currentContent = store.getters['content/current'];\n      const { selectedFormat } = this;\n      store.dispatch('queue/enqueue', async () => {\n        const tokenToRefresh = store.getters['workspace/sponsorToken'];\n        const sponsorToken = tokenToRefresh && await googleHelper.refreshToken(tokenToRefresh);\n\n        try {\n          const { body } = await networkSvc.request({\n            method: 'POST',\n            url: 'pandocExport',\n            params: {\n              idToken: sponsorToken && sponsorToken.idToken,\n              format: selectedFormat,\n              options: JSON.stringify(store.getters['data/computedSettings'].pandoc),\n              metadata: JSON.stringify(currentContent.properties),\n            },\n            body: JSON.stringify(editorSvc.getPandocAst()),\n            blob: true,\n            timeout: 60000,\n          });\n          FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`);\n          badgeSvc.addBadge('exportPandoc');\n        } catch (err) {\n          if (err.status === 401) {\n            store.dispatch('modal/open', 'sponsorOnly');\n          } else {\n            console.error(err); // eslint-disable-line no-console\n            store.dispatch('notification/error', err);\n          }\n        }\n      });\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/PdfExportModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Export to PDF\">\n    <div class=\"modal__content\">\n      <p>Please choose a template for your <b>PDF export</b>.</p>\n      <form-entry label=\"Template\">\n        <select class=\"textfield\" slot=\"field\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport FileSaver from 'file-saver';\nimport exportSvc from '../../services/exportSvc';\nimport networkSvc from '../../services/networkSvc';\nimport googleHelper from '../../services/providers/helpers/googleHelper';\nimport modalTemplate from './common/modalTemplate';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default modalTemplate({\n  computedLocalSettings: {\n    selectedTemplate: 'pdfExportTemplate',\n  },\n  methods: {\n    async resolve() {\n      this.config.resolve();\n      const currentFile = store.getters['file/current'];\n      store.dispatch('queue/enqueue', async () => {\n        const [sponsorToken, html] = await Promise.all([\n          Promise.resolve().then(() => {\n            const tokenToRefresh = store.getters['workspace/sponsorToken'];\n            return tokenToRefresh && googleHelper.refreshToken(tokenToRefresh);\n          }),\n          exportSvc.applyTemplate(\n            currentFile.id,\n            this.allTemplatesById[this.selectedTemplate],\n            true,\n          ),\n        ]);\n\n        try {\n          const { body } = await networkSvc.request({\n            method: 'POST',\n            url: 'pdfExport',\n            params: {\n              idToken: sponsorToken && sponsorToken.idToken,\n              options: JSON.stringify(store.getters['data/computedSettings'].wkhtmltopdf),\n            },\n            body: html,\n            blob: true,\n            timeout: 60000,\n          });\n          FileSaver.saveAs(body, `${currentFile.name}.pdf`);\n          badgeSvc.addBadge('exportPdf');\n        } catch (err) {\n          if (err.status === 401) {\n            store.dispatch('modal/open', 'sponsorOnly');\n          } else {\n            console.error(err); // eslint-disable-line no-console\n            store.dispatch('notification/error', err);\n          }\n        }\n      });\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/PublishManagementModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--publish-management\" aria-label=\"Manage publication locations\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-upload></icon-upload>\n      </div>\n      <p v-if=\"publishLocations.length\"><b>{{currentFileName}}</b> is published to the following location(s):</p>\n      <p v-else><b>{{currentFileName}}</b> is not published yet.</p>\n      <div>\n        <div class=\"publish-entry flex flex--column\" v-for=\"location in publishLocations\" :key=\"location.id\">\n          <div class=\"publish-entry__header flex flex--row flex--align-center\">\n            <div class=\"publish-entry__icon flex flex--column flex--center\">\n              <icon-provider :provider-id=\"location.providerId\"></icon-provider>\n            </div>\n            <div class=\"publish-entry__description\">\n              {{location.description}}\n            </div>\n            <div class=\"publish-entry__buttons flex flex--row flex--center\">\n              <button class=\"publish-entry__button button\" @click=\"remove(location)\" v-title=\"'Remove location'\">\n                <icon-delete></icon-delete>\n              </button>\n            </div>\n          </div>\n          <div class=\"publish-entry__row flex flex--row flex--align-center\">\n            <div class=\"publish-entry__url\">\n              {{location.url}}\n            </div>\n            <div class=\"publish-entry__buttons flex flex--row flex--center\" v-if=\"location.url\">\n              <button class=\"publish-entry__button button\" v-clipboard=\"location.url\" @click=\"info('Location URL copied to clipboard!')\" v-title=\"'Copy URL'\">\n                <icon-content-copy></icon-content-copy>\n              </button>\n              <a class=\"publish-entry__button button\" v-if=\"location.url\" :href=\"location.url\" target=\"_blank\" v-title=\"'Open location'\">\n                <icon-open-in-new></icon-open-in-new>\n              </a>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"modal__info\" v-if=\"publishLocations.length\">\n        <b>Tip:</b> Removing a location won't delete any file.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Close</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters, mapActions } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default {\n  components: {\n    ModalInner,\n  },\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    ...mapGetters('publishLocation', {\n      publishLocations: 'current',\n    }),\n    currentFileName() {\n      return store.getters['file/current'].name;\n    },\n  },\n  methods: {\n    ...mapActions('notification', [\n      'info',\n    ]),\n    remove(location) {\n      store.commit('publishLocation/deleteItem', location.id);\n      badgeSvc.addBadge('removePublishLocation');\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.publish-entry {\n  margin: 1.5em 0;\n  height: auto;\n  font-size: 17px;\n  line-height: 1.5;\n}\n\n$button-size: 30px;\n$small-button-size: 22px;\n\n.publish-entry__header {\n  line-height: $button-size;\n}\n\n.publish-entry__row {\n  border-top: 1px solid $hr-color;\n  line-height: $small-button-size;\n}\n\n.publish-entry__icon {\n  height: 22px;\n  width: 22px;\n  margin-right: 0.75rem;\n  flex: none;\n}\n\n.publish-entry__description {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n.publish-entry__url {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  opacity: 0.5;\n  font-size: 0.67em;\n}\n\n.publish-entry__buttons {\n  margin-left: 0.75rem;\n\n  .publish-entry__row & {\n    margin-left: 0.5rem;\n  }\n}\n\n.publish-entry__button {\n  width: $button-size;\n  height: $button-size;\n  padding: 4px;\n  background-color: transparent;\n  opacity: 0.75;\n\n  .publish-entry__row & {\n    width: $small-button-size;\n    height: $small-button-size;\n    padding: 4px;\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/SettingsModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--settings\" aria-label=\"Settings\">\n    <div class=\"modal__content\">\n      <div class=\"tabs flex flex--row\">\n        <tab :active=\"tab === 'custom'\" @click=\"tab = 'custom'\">\n          Custom settings\n        </tab>\n        <tab :active=\"tab === 'default'\" @click=\"tab = 'default'\">\n          Default settings\n        </tab>\n      </div>\n      <div class=\"form-entry\" v-if=\"tab === 'custom'\" role=\"tabpanel\" aria-label=\"Custom settings\">\n        <label class=\"form-entry__label\">YAML</label>\n        <div class=\"form-entry__field form-entry__field--code-editor\">\n          <code-editor lang=\"yaml\" :value=\"customSettings\" key=\"custom-settings\" @changed=\"setCustomSettings\"></code-editor>\n        </div>\n      </div>\n      <div class=\"form-entry\" v-else-if=\"tab === 'default'\" role=\"tabpanel\" aria-label=\"Default settings\">\n        <label class=\"form-entry__label\">YAML</label>\n        <div class=\"form-entry__field form-entry__field--code-editor\">\n          <code-editor lang=\"yaml\" :value=\"defaultSettings\" key=\"default-settings\" disabled=\"true\"></code-editor>\n        </div>\n      </div>\n      <div class=\"modal__error modal__error--settings\">{{error}}</div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport yaml from 'js-yaml';\nimport { mapGetters } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport Tab from './common/Tab';\nimport CodeEditor from '../CodeEditor';\nimport defaultSettings from '../../data/defaults/defaultSettings.yml';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nconst emptySettings = `# Add your custom settings here to override the\n# default settings.\n`;\n\nexport default {\n  components: {\n    ModalInner,\n    Tab,\n    CodeEditor,\n  },\n  data: () => ({\n    tab: 'custom',\n    defaultSettings,\n    customSettings: null,\n    error: null,\n  }),\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    strippedCustomSettings() {\n      return this.customSettings === emptySettings ? '\\n' : this.customSettings.replace(/\\t/g, '  ');\n    },\n  },\n  created() {\n    const settings = store.getters['data/settings'];\n    this.setCustomSettings(settings === '\\n' ? emptySettings : settings);\n  },\n  methods: {\n    setCustomSettings(value) {\n      this.customSettings = value;\n      try {\n        yaml.safeLoad(this.strippedCustomSettings);\n        this.error = null;\n      } catch (e) {\n        this.error = e.message;\n      }\n    },\n    async resolve() {\n      if (!this.error) {\n        const settings = this.strippedCustomSettings;\n        await store.dispatch('data/setSettings', settings);\n        const customSettings = yaml.safeLoad(settings);\n        if (customSettings.shortcuts) {\n          badgeSvc.addBadge('changeShortcuts');\n        }\n        const computedSettings = store.getters['data/computedSettings'];\n        const customSettingsCount = Object\n          .keys(customSettings)\n          .filter(key => key !== 'shortcuts' && computedSettings[key])\n          .length;\n        if (customSettingsCount) {\n          badgeSvc.addBadge('changeSettings');\n        }\n        this.config.resolve(settings);\n      }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.modal__inner-1.modal__inner-1--settings {\n  max-width: 560px;\n}\n\n.modal__error--settings {\n  white-space: pre-wrap;\n  font-family: $font-family-monospace;\n  font-size: $font-size-monospace;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/SponsorModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--sponsor\" aria-label=\"Sponsor\">\n    <div class=\"modal__content\">\n      <p>Please choose a <b>PayPal</b> option:</p>\n      <a class=\"paypal-option button flex flex--row flex--center\" v-for=\"button in buttons\" :key=\"button.id\" :href=\"button.link\">\n        <div class=\"flex flex--column\">\n          <div>{{button.price}}<div class=\"paypal-option__offer\" v-if=\"button.offer\">{{button.offer}}</div></div>\n          <span>{{button.description}}</span>\n        </div>\n      </a>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport utils from '../../services/utils';\nimport store from '../../store';\n\nexport default {\n  components: {\n    ModalInner,\n  },\n  data() {\n    const sponsorToken = store.getters['workspace/sponsorToken'];\n    const makeButton = (id, price, description, offer) => {\n      const params = {\n        cmd: '_s-xclick',\n        hosted_button_id: id,\n        custom: sponsorToken.sub,\n      };\n      return {\n        id,\n        price,\n        description,\n        offer,\n        link: utils.addQueryParams('https://www.paypal.com/cgi-bin/webscr', params),\n      };\n    };\n\n    return {\n      buttons: sponsorToken ? [\n        makeButton('QD7SFZS79D2AL', '$5', '3 months sponsorship'),\n        makeButton('WG64NCFL9TQZJ', '$15', '1 year sponsorship', '-25%'),\n        makeButton('G2E7MN873EQ3U', '$25', '2 years sponsorship', '-37%'),\n        makeButton('JQJT7ARKYC7FC', '$50', '5 years sponsorship', '-50%'),\n      ] : [],\n    };\n  },\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n  },\n  methods: {\n    sponsor() {\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.modal__inner-1.modal__inner-1--sponsor {\n  max-width: 400px;\n}\n\n.paypal-option {\n  text-align: center;\n  padding: 10px;\n  height: auto;\n  font-size: 2.3em;\n  margin: 0.75rem 0;\n  line-height: 1.2;\n  text-transform: none;\n\n  span {\n    display: inline-block;\n    font-size: 0.75rem;\n    opacity: 0.6;\n    white-space: normal;\n    line-height: 1.5;\n  }\n\n  .paypal-option__offer {\n    float: right;\n    font-size: 0.6rem;\n    font-weight: 600;\n    padding: 0.1em 0.2em;\n    background-color: darken($error-color, 10);\n    border-radius: 3px;\n    color: #fff;\n    margin-left: -0.5em;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/SyncManagementModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--sync-management\" aria-label=\"Manage synchronized locations\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-sync></icon-sync>\n      </div>\n      <p v-if=\"syncLocations.length\"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>\n      <p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>\n      <div>\n        <div class=\"sync-entry flex flex--column\" v-for=\"location in syncLocations\" :key=\"location.id\">\n          <div class=\"sync-entry__header flex flex--row flex--align-center\">\n            <div class=\"sync-entry__icon flex flex--column flex--center\">\n              <icon-provider :provider-id=\"location.providerId\"></icon-provider>\n            </div>\n            <div class=\"sync-entry__description\">\n              {{location.description}}\n            </div>\n            <div class=\"sync-entry__buttons flex flex--row flex--center\">\n              <button class=\"sync-entry__button button\" @click=\"remove(location)\" v-title=\"'Remove location'\">\n                <icon-delete></icon-delete>\n              </button>\n            </div>\n          </div>\n          <div class=\"sync-entry__row flex flex--row flex--align-center\">\n            <div class=\"sync-entry__url\">\n              {{location.url || 'Google Drive app data'}}\n            </div>\n            <div class=\"sync-entry__buttons flex flex--row flex--center\" v-if=\"location.url\">\n              <button class=\"sync-entry__button button\" v-clipboard=\"location.url\" @click=\"info('Location URL copied to clipboard!')\" v-title=\"'Copy URL'\">\n                <icon-content-copy></icon-content-copy>\n              </button>\n              <a class=\"sync-entry__button button\" v-if=\"location.url\" :href=\"location.url\" target=\"_blank\" v-title=\"'Open location'\">\n                <icon-open-in-new></icon-open-in-new>\n              </a>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"modal__info\" v-if=\"syncLocations.length\">\n        <b>Tip:</b> Removing a location won't delete any file.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Close</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters, mapActions } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\n\nexport default {\n  components: {\n    ModalInner,\n  },\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    ...mapGetters('syncLocation', {\n      syncLocations: 'currentWithWorkspaceSyncLocation',\n    }),\n    currentFileName() {\n      return store.getters['file/current'].name;\n    },\n  },\n  methods: {\n    ...mapActions('notification', [\n      'info',\n    ]),\n    remove(location) {\n      if (location.id === 'main') {\n        this.info('This location can not be removed.');\n      } else {\n        store.commit('syncLocation/deleteItem', location.id);\n        badgeSvc.addBadge('removeSyncLocation');\n      }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.sync-entry {\n  margin: 1.5em 0;\n  height: auto;\n  font-size: 17px;\n  line-height: 1.5;\n}\n\n$button-size: 30px;\n$small-button-size: 22px;\n\n.sync-entry__header {\n  line-height: $button-size;\n}\n\n.sync-entry__row {\n  border-top: 1px solid $hr-color;\n  line-height: $small-button-size;\n}\n\n.sync-entry__icon {\n  height: 22px;\n  width: 22px;\n  margin-right: 0.75rem;\n  flex: none;\n}\n\n.sync-entry__description {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n.sync-entry__url {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  opacity: 0.5;\n  font-size: 0.67em;\n}\n\n.sync-entry__buttons {\n  margin-left: 0.75rem;\n\n  .sync-entry__row & {\n    margin-left: 0.5rem;\n  }\n}\n\n.sync-entry__button {\n  width: $button-size;\n  height: $button-size;\n  padding: 4px;\n  background-color: transparent;\n  opacity: 0.75;\n\n  .sync-entry__row & {\n    width: $small-button-size;\n    height: $small-button-size;\n    padding: 4px;\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/TemplatesModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--templates\" aria-label=\"Manage templates\">\n    <div class=\"modal__content\">\n      <div class=\"form-entry\">\n        <label class=\"form-entry__label\" for=\"template\">Template</label>\n        <div class=\"form-entry__field\">\n          <input v-if=\"isEditing\" id=\"template\" type=\"text\" class=\"textfield\" v-focus @blur=\"submitEdit()\" @keydown.enter=\"submitEdit()\" @keydown.esc.stop=\"submitEdit(true)\" v-model=\"editingName\">\n          <select v-else id=\"template\" v-model=\"selectedId\" class=\"textfield\">\n            <option v-for=\"(template, id) in templates\" :key=\"id\" :value=\"id\">\n              {{ template.name }}\n            </option>\n          </select>\n        </div>\n        <div class=\"form-entry__actions flex flex--row flex--end\">\n          <button class=\"form-entry__button button\" @click=\"create\" v-title=\"'New template'\">\n            <icon-file-plus></icon-file-plus>\n          </button>\n          <button class=\"form-entry__button button\" @click=\"copy\" v-title=\"'Copy template'\">\n            <icon-file-multiple></icon-file-multiple>\n          </button>\n          <button v-if=\"!isReadOnly\" class=\"form-entry__button button\" @click=\"isEditing = true\" v-title=\"'Rename template'\">\n            <icon-pen></icon-pen>\n          </button>\n          <button v-if=\"!isReadOnly\" class=\"form-entry__button button\" @click=\"remove\" v-title=\"'Remove template'\">\n            <icon-delete></icon-delete>\n          </button>\n        </div>\n      </div>\n      <div class=\"form-entry\">\n        <label class=\"form-entry__label\">Value</label>\n        <div class=\"form-entry__field\" v-for=\"(template, id) in templates\" :key=\"id\" v-if=\"id === selectedId\">\n          <code-editor lang=\"handlebars\" :value=\"template.value\" :disabled=\"isReadOnly\" @changed=\"template.value = $event\"></code-editor>\n        </div>\n      </div>\n      <div v-if=\"!isReadOnly\">\n        <a href=\"javascript:void(0)\" v-if=\"!showHelpers\" @click=\"showHelpers = true\">Add helpers</a>\n        <div class=\"form-entry\" v-else>\n          <br>\n          <label class=\"form-entry__label\">Helpers</label>\n          <div class=\"form-entry__field\" v-for=\"(template, id) in templates\" :key=\"id\" v-if=\"id === selectedId\">\n            <code-editor lang=\"javascript\" :value=\"template.helpers\" @changed=\"template.helpers = $event\"></code-editor>\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport utils from '../../services/utils';\nimport badgeSvc from '../../services/badgeSvc';\nimport ModalInner from './common/ModalInner';\nimport CodeEditor from '../CodeEditor';\nimport emptyTemplateValue from '../../data/empties/emptyTemplateValue.html';\nimport emptyTemplateHelpers from '!raw-loader!../../data/empties/emptyTemplateHelpers.js'; // eslint-disable-line\nimport store from '../../store';\n\nconst collator = new Intl.Collator(undefined, { sensitivity: 'base' });\n\nfunction fillEmptyFields(template) {\n  if (template.value === '\\n') {\n    template.value = emptyTemplateValue;\n  }\n  if (template.helpers === '\\n') {\n    template.helpers = emptyTemplateHelpers;\n  }\n}\n\nexport default {\n  components: {\n    ModalInner,\n    CodeEditor,\n  },\n  data: () => ({\n    selectedId: '',\n    templates: {},\n    showHelpers: false,\n    isEditing: false,\n    editingName: '',\n  }),\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    isReadOnly() {\n      return this.templates[this.selectedId].isAdditional;\n    },\n  },\n  created() {\n    this.$watch(\n      () => store.getters['data/allTemplatesById'],\n      (allTemplatesById) => {\n        const templates = {};\n        // Sort templates by name\n        Object.entries(allTemplatesById)\n          .sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))\n          .forEach(([id, template]) => {\n            const templateClone = utils.deepCopy(template);\n            fillEmptyFields(templateClone);\n            templates[id] = templateClone;\n          });\n        this.templates = templates;\n        this.selectedId = this.config.selectedId;\n        if (!templates[this.selectedId]) {\n          [this.selectedId] = Object.keys(templates);\n        }\n        this.isEditing = false;\n      },\n      { immediate: true },\n    );\n    this.$watch('selectedId', (selectedId) => {\n      const template = this.templates[selectedId];\n      this.showHelpers = template.helpers !== emptyTemplateHelpers;\n      this.editingName = template.name;\n    }, { immediate: true });\n  },\n  methods: {\n    create() {\n      const template = {\n        name: 'New template',\n        value: '\\n',\n        helpers: '\\n',\n      };\n      fillEmptyFields(template);\n      this.selectedId = utils.uid();\n      this.templates[this.selectedId] = template;\n      this.isEditing = true;\n    },\n    copy() {\n      const template = utils.deepCopy(this.templates[this.selectedId]);\n      template.name += ' copy';\n      delete template.isAdditional;\n      this.selectedId = utils.uid();\n      this.templates[this.selectedId] = template;\n      this.isEditing = true;\n    },\n    remove() {\n      delete this.templates[this.selectedId];\n      [this.selectedId] = Object.keys(this.templates);\n    },\n    submitEdit(cancel) {\n      const template = this.templates[this.selectedId];\n      if (!cancel && this.editingName) {\n        template.name = utils.sanitizeName(this.editingName);\n      } else {\n        this.editingName = template.name;\n      }\n      setTimeout(() => { // For the form-entry to get the blur event\n        this.isEditing = false;\n      }, 1);\n    },\n    async resolve() {\n      const oldTemplateIds = Object.keys(store.getters['data/templatesById']);\n      await store.dispatch('data/setTemplatesById', this.templates);\n      const newTemplateIds = Object.keys(store.getters['data/templatesById']);\n      const createdCount = newTemplateIds\n        .filter(id => !oldTemplateIds.includes(id))\n        .length;\n      const removedCount = oldTemplateIds\n        .filter(id => !newTemplateIds.includes(id))\n        .length;\n      if (createdCount) {\n        badgeSvc.addBadge('addTemplate');\n      }\n      if (removedCount) {\n        badgeSvc.addBadge('removeTemplate');\n      }\n      this.config.resolve({\n        templates: this.templates,\n        selectedId: this.selectedId,\n      });\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.modal__inner-1.modal__inner-1--templates {\n  max-width: 600px;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/WorkspaceManagementModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--workspace-management\" aria-label=\"Manage workspaces\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-database></icon-database>\n      </div>\n      <p>The following workspaces are accessible:</p>\n      <div class=\"workspace-entry flex flex--column\" v-for=\"(workspace, id) in workspacesById\" :key=\"id\">\n        <div class=\"flex flex--column\">\n          <div class=\"workspace-entry__header flex flex--row flex--align-center\">\n            <div class=\"workspace-entry__icon\">\n              <icon-provider :provider-id=\"workspace.providerId\"></icon-provider>\n            </div>\n            <input class=\"text-input\" type=\"text\" v-if=\"editedId === id\" v-focus @blur=\"submitEdit()\" @keydown.enter=\"submitEdit()\" @keydown.esc.stop=\"submitEdit(true)\" v-model=\"editingName\">\n            <div class=\"workspace-entry__name\" v-else>{{workspace.name}}</div>\n            <div class=\"workspace-entry__buttons flex flex--row\">\n              <button class=\"workspace-entry__button button\" @click=\"edit(id)\" v-title=\"'Edit name'\">\n                <icon-pen></icon-pen>\n              </button>\n              <button class=\"workspace-entry__button button\" @click=\"remove(id)\" v-title=\"'Remove'\">\n                <icon-delete></icon-delete>\n              </button>\n            </div>\n          </div>\n          <div class=\"workspace-entry__row flex flex--row flex--align-center\">\n            <div class=\"workspace-entry__url\">\n              {{workspace.url}}\n            </div>\n            <div class=\"workspace-entry__buttons flex flex--row\">\n              <button class=\"workspace-entry__button button\" v-clipboard=\"workspace.url\" @click=\"info('Workspace URL copied to clipboard!')\" v-title=\"'Copy URL'\">\n                <icon-content-copy></icon-content-copy>\n              </button>\n              <a class=\"workspace-entry__button button\" :href=\"workspace.url\" target=\"_blank\" v-title=\"'Open workspace'\">\n                <icon-open-in-new></icon-open-in-new>\n              </a>\n            </div>\n          </div>\n          <div class=\"workspace-entry__row flex flex--row flex--align-center\" v-if=\"workspace.locationUrl\">\n            <div class=\"workspace-entry__url\">\n              {{workspace.locationUrl}}\n            </div>\n            <div class=\"workspace-entry__buttons flex flex--row\">\n              <button class=\"workspace-entry__button button\" v-clipboard=\"workspace.locationUrl\" @click=\"info('Workspace URL copied to clipboard!')\" v-title=\"'Copy URL'\">\n                <icon-content-copy></icon-content-copy>\n              </button>\n              <a class=\"workspace-entry__button button\" :href=\"workspace.locationUrl\" target=\"_blank\" v-title=\"'Open workspace location'\">\n                <icon-open-in-new></icon-open-in-new>\n              </a>\n            </div>\n          </div>\n          <div>\n            <span class=\"workspace-entry__offline\" v-if=\"availableOffline[id]\">\n              available offline\n            </span>\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Close</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport Vue from 'vue';\nimport { mapGetters, mapActions } from 'vuex';\nimport ModalInner from './common/ModalInner';\nimport workspaceSvc from '../../services/workspaceSvc';\nimport store from '../../store';\nimport badgeSvc from '../../services/badgeSvc';\nimport localDbSvc from '../../services/localDbSvc';\n\nexport default {\n  components: {\n    ModalInner,\n  },\n  data: () => ({\n    editedId: null,\n    editingName: '',\n    availableOffline: {},\n  }),\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n    ...mapGetters('workspace', [\n      'workspacesById',\n      'mainWorkspace',\n      'currentWorkspace',\n    ]),\n  },\n  methods: {\n    ...mapActions('notification', [\n      'info',\n    ]),\n    edit(id) {\n      this.editedId = id;\n      this.editingName = this.workspacesById[id].name;\n    },\n    submitEdit(cancel) {\n      const workspace = this.workspacesById[this.editedId];\n      if (workspace) {\n        if (!cancel && this.editingName && this.editingName !== workspace.name) {\n          store.dispatch('workspace/patchWorkspacesById', {\n            [this.editedId]: {\n              ...workspace,\n              name: this.editingName,\n            },\n          });\n          badgeSvc.addBadge('renameWorkspace');\n        } else {\n          this.editingName = workspace.name;\n        }\n      }\n      this.editedId = null;\n    },\n    async remove(id) {\n      if (id === this.mainWorkspace.id) {\n        this.info('Your main workspace can not be removed.');\n      } else if (id === this.currentWorkspace.id) {\n        this.info('Please close the workspace before removing it.');\n      } else {\n        try {\n          await store.dispatch('modal/open', 'removeWorkspace');\n          workspaceSvc.removeWorkspace(id);\n          badgeSvc.addBadge('removeWorkspace');\n        } catch (e) { /* Cancel */ }\n      }\n    },\n  },\n  created() {\n    Object.keys(this.workspacesById).forEach(async (workspaceId) => {\n      const cancel = localDbSvc.getWorkspaceItems(workspaceId, () => {\n        Vue.set(this.availableOffline, workspaceId, true);\n        cancel();\n      });\n    });\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../styles/variables.scss';\n\n.workspace-entry {\n  margin: 1.75em 0;\n  height: auto;\n  font-size: 17px;\n  line-height: 1.5;\n}\n\n$button-size: 30px;\n$small-button-size: 22px;\n\n.workspace-entry__header {\n  line-height: $button-size;\n\n  .text-input {\n    border: 1px solid $link-color;\n    padding: 0 5px;\n    line-height: $button-size;\n    height: $button-size;\n  }\n}\n\n.workspace-entry__row {\n  border-top: 1px solid $hr-color;\n  line-height: $small-button-size;\n}\n\n.workspace-entry__icon {\n  height: 22px;\n  width: 22px;\n  margin-right: 0.75rem;\n  flex: none;\n}\n\n.workspace-entry__name {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  font-weight: bold;\n}\n\n.workspace-entry__url {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  opacity: 0.5;\n  font-size: 0.67em;\n}\n\n.workspace-entry__buttons {\n  margin-left: 0.75rem;\n\n  .workspace-entry__row & {\n    margin-left: 0.5rem;\n  }\n}\n\n.workspace-entry__button {\n  width: $button-size;\n  height: $button-size;\n  padding: 4px;\n  background-color: transparent;\n  opacity: 0.75;\n\n  .workspace-entry__row & {\n    width: $small-button-size;\n    height: $small-button-size;\n    padding: 4px;\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n}\n\n.workspace-entry__offline {\n  font-size: 0.8rem;\n  line-height: 1;\n  padding: 0.15em 0.35em;\n  border-radius: 3px;\n  color: #fff;\n  background-color: darken($error-color, 10);\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/common/FormEntry.vue",
    "content": "<template>\n  <div class=\"form-entry\" :error=\"error\">\n    <label class=\"form-entry__label\" :for=\"uid\">{{label}}<span class=\"form-entry__label-info\" v-if=\"info\"> &mdash; {{info}}</span></label>\n    <div class=\"form-entry__field\">\n      <slot name=\"field\"></slot>\n    </div>\n    <slot></slot>\n  </div>\n</template>\n\n<script>\nimport utils from '../../../services/utils';\n\nexport default {\n  props: ['label', 'info', 'error'],\n  data: () => ({\n    uid: utils.uid(),\n  }),\n  mounted() {\n    this.$el.querySelector('input,select').id = this.uid;\n  },\n};\n</script>\n"
  },
  {
    "path": "src/components/modals/common/ModalInner.vue",
    "content": "<template>\n  <div class=\"modal__inner-1\" role=\"dialog\">\n    <div class=\"modal__inner-2\">\n      <button class=\"modal__close-button button not-tabbable\" @click=\"config.reject()\" v-title=\"'Close modal'\">\n        <icon-close></icon-close>\n      </button>\n      <slot></slot>\n    </div>\n  </div>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\n\nexport default {\n  computed: {\n    ...mapGetters('modal', [\n      'config',\n    ]),\n  },\n};\n</script>\n\n<style lang=\"scss\">\n@import '../../../styles/variables.scss';\n\n.modal__close-button {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  color: rgba(0, 0, 0, 0.5);\n  width: 32px;\n  height: 32px;\n  padding: 2px;\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.67);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/common/Tab.vue",
    "content": "<template>\n  <div class=\"tabs__tab flex flex--row\" :class=\"{'tabs__tab--active': active}\" role=\"tab\">\n    <a class=\"flex flex--column flex--center\" href=\"javascript:void(0)\" @click=\"$emit('click')\">\n      <slot></slot>\n    </a>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: ['active'],\n};\n</script>\n"
  },
  {
    "path": "src/components/modals/common/modalTemplate.js",
    "content": "import ModalInner from './ModalInner';\nimport FormEntry from './FormEntry';\nimport store from '../../../store';\n\nconst collator = new Intl.Collator(undefined, { sensitivity: 'base' });\n\nexport default (desc) => {\n  const component = {\n    ...desc,\n    data: () => ({\n      ...desc.data ? desc.data() : {},\n      errorTimeouts: {},\n    }),\n    components: {\n      ...desc.components || {},\n      ModalInner,\n      FormEntry,\n    },\n    computed: {\n      ...desc.computed || {},\n      config() {\n        return store.getters['modal/config'];\n      },\n      currentFileName() {\n        return store.getters['file/current'].name;\n      },\n    },\n    methods: {\n      ...desc.methods || {},\n      openFileProperties: () => store.dispatch('modal/open', 'fileProperties'),\n      setError(name) {\n        clearTimeout(this.errorTimeouts[name]);\n        const formEntry = this.$el.querySelector(`.form-entry[error=${name}]`);\n        if (formEntry) {\n          formEntry.classList.add('form-entry--error');\n          this.errorTimeouts[name] = setTimeout(() => {\n            formEntry.classList.remove('form-entry--error');\n          }, 1000);\n        }\n      },\n    },\n  };\n  Object.entries(desc.computedLocalSettings || {}).forEach(([key, id]) => {\n    component.computed[key] = {\n      get() {\n        return store.getters['data/localSettings'][id];\n      },\n      set(value) {\n        store.dispatch('data/patchLocalSettings', {\n          [id]: value,\n        });\n      },\n    };\n    if (key === 'selectedTemplate') {\n      component.computed.allTemplatesById = () => {\n        const allTemplatesById = store.getters['data/allTemplatesById'];\n        const sortedTemplatesById = {};\n        Object.entries(allTemplatesById)\n          .sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))\n          .forEach(([templateId, template]) => {\n            sortedTemplatesById[templateId] = template;\n          });\n        return sortedTemplatesById;\n      };\n      // Make use of `function` to have `this` bound to the component\n      component.methods.configureTemplates = async function () { // eslint-disable-line func-names\n        const { selectedId } = await store.dispatch('modal/open', {\n          type: 'templates',\n          selectedId: this.selectedTemplate,\n        });\n        store.dispatch('data/patchLocalSettings', {\n          [id]: selectedId,\n        });\n      };\n    }\n  });\n  component.computedLocalSettings = null;\n  return component;\n};\n"
  },
  {
    "path": "src/components/modals/providers/BloggerPagePublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to Blogger Page\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"bloggerPage\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>Blogger Page</b>.</p>\n      <form-entry label=\"Blog URL\" error=\"blogUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"blogUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> http://example.blogger.com/\n        </div>\n      </form-entry>\n      <form-entry label=\"Existing page ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"pageId\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n      <div class=\"modal__info\">\n        <b>ProTip:</b> You can provide a value for <code>title</code> in the <a href=\"javascript:void(0)\" @click=\"openFileProperties\">file properties</a>.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport bloggerPageProvider from '../../../services/providers/bloggerPageProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    pageId: '',\n  }),\n  computedLocalSettings: {\n    blogUrl: 'bloggerBlogUrl',\n    selectedTemplate: 'bloggerPublishTemplate',\n  },\n  methods: {\n    resolve() {\n      if (!this.blogUrl) {\n        this.setError('blogUrl');\n      } else {\n        // Return new location\n        const location = bloggerPageProvider.makeLocation(\n          this.config.token,\n          this.blogUrl,\n          this.pageId,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/BloggerPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to Blogger\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"blogger\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>Blogger</b> site.</p>\n      <form-entry label=\"Blog URL\" error=\"blogUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"blogUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> http://example.blogger.com/\n        </div>\n      </form-entry>\n      <form-entry label=\"Existing post ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"postId\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n      <div class=\"modal__info\">\n        <b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code>,\n        <code>status</code> and <code>date</code> in the <a href=\"javascript:void(0)\" @click=\"openFileProperties\">file properties</a>.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport bloggerProvider from '../../../services/providers/bloggerProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    postId: '',\n  }),\n  computedLocalSettings: {\n    blogUrl: 'bloggerBlogUrl',\n    selectedTemplate: 'bloggerPublishTemplate',\n  },\n  methods: {\n    resolve() {\n      if (!this.blogUrl) {\n        this.setError('blogUrl');\n      } else {\n        // Return new location\n        const location = bloggerProvider.makeLocation(\n          this.config.token,\n          this.blogUrl,\n          this.postId,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/CouchdbCredentialsModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Insert image\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"couchdb\"></icon-provider>\n      </div>\n      <p>Please provide your credentials to login to <b>CouchDB</b>.</p>\n      <form-entry label=\"Name\" error=\"name\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"name\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <form-entry label=\"Password\" error=\"password\">\n        <input slot=\"field\" class=\"textfield\" type=\"password\" v-model.trim=\"password\" @keydown.enter=\"resolve()\">\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\nimport store from '../../../store';\n\nexport default modalTemplate({\n  data: () => ({\n    name: '',\n    password: '',\n  }),\n  created() {\n    this.name = this.config.token.name;\n    this.password = this.config.token.password;\n  },\n  methods: {\n    resolve() {\n      if (!this.name) {\n        this.setError('name');\n      }\n      if (!this.password) {\n        this.setError('password');\n      }\n      if (this.name && this.password) {\n        const token = {\n          ...this.config.token,\n          name: this.name,\n          password: this.password,\n        };\n        store.dispatch('data/addCouchdbToken', token);\n        this.config.resolve();\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/CouchdbWorkspaceModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Add CouchDB workspace\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"couchdb\"></icon-provider>\n      </div>\n      <p>Create a workspace synced with a <b>CouchDB</b> database.</p>\n      <form-entry label=\"Database URL\" error=\"dbUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"dbUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://instance.smileupps.com/stackedit-workspace\n        </div>\n        <div class=\"form-entry__actions\">\n          <a href=\"https://community.stackedit.io/t/couchdb-workspace-setup/\" target=\"_blank\">How to setup?</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    dbUrl: '',\n  }),\n  methods: {\n    resolve() {\n      if (!this.dbUrl) {\n        this.setError('dbUrl');\n      } else {\n        const url = utils.addQueryParams('app', {\n          providerId: 'couchdbWorkspace',\n          dbUrl: this.dbUrl,\n        }, true);\n        this.config.resolve();\n        window.open(url);\n      }\n    },\n  },\n});\n</script>\n\n<style lang=\"scss\">\n.couchdb-workspace__info {\n  font-size: 0.8em;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/providers/DropboxAccountModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Link Dropbox account\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"dropbox\"></icon-provider>\n      </div>\n      <p>Link your <b>Dropbox</b> account to <b>StackEdit</b>.</p>\n      <div class=\"form-entry\">\n        <div class=\"form-entry__checkbox\">\n          <label>\n            <input type=\"checkbox\" v-model=\"restrictedAccess\"> Restrict access\n          </label>\n          <div class=\"form-entry__info\">\n            If checked, access will be restricted to the <b>/Applications/StackEdit (restricted)</b> folder.\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  computedLocalSettings: {\n    restrictedAccess: 'dropboxRestrictedAccess',\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/DropboxPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to Dropbox\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"dropbox\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>Dropbox</b>.</p>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.html<br>\n          If the file exists, it will be overwritten.\n        </div>\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport dropboxProvider from '../../../services/providers/dropboxProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    path: '',\n  }),\n  computedLocalSettings: {\n    selectedTemplate: 'dropboxPublishTemplate',\n  },\n  created() {\n    this.path = `/${this.currentFileName}.html`;\n  },\n  methods: {\n    resolve() {\n      if (!dropboxProvider.checkPath(this.path)) {\n        this.setError('path');\n      } else {\n        // Return new location\n        const location = dropboxProvider.makeLocation(this.config.token, this.path);\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/DropboxSaveModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with Dropbox\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"dropbox\"></icon-provider>\n      </div>\n      <p>Save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synced.</p>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.md<br>\n          If the file exists, it will be overwritten.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport dropboxProvider from '../../../services/providers/dropboxProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    path: '',\n  }),\n  created() {\n    this.path = `/${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      if (!dropboxProvider.checkPath(this.path)) {\n        this.setError('path');\n      } else {\n        // Return new location\n        const location = dropboxProvider.makeLocation(this.config.token, this.path);\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GistPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to Gist\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gist\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to a <b>Gist</b>.</p>\n      <form-entry label=\"Filename\" error=\"filename\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"filename\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <div class=\"form-entry\">\n        <div class=\"form-entry__checkbox\">\n          <label>\n            <input type=\"checkbox\" v-model=\"isPublic\"> Public\n          </label>\n        </div>\n      </div>\n      <form-entry label=\"Existing Gist ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"gistId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If the file exists in the Gist, it will be overwritten.\n        </div>\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n      <div class=\"modal__info\">\n        <b>ProTip:</b> You can provide a value for <code>title</code> in the <a href=\"javascript:void(0)\" @click=\"openFileProperties\">file properties</a>.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport gistProvider from '../../../services/providers/gistProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    filename: '',\n    gistId: '',\n  }),\n  computedLocalSettings: {\n    isPublic: 'gistIsPublic',\n    selectedTemplate: 'gistPublishTemplate',\n  },\n  created() {\n    this.filename = `${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      if (!this.filename) {\n        this.setError('filename');\n      } else {\n        // Return new location\n        const location = gistProvider.makeLocation(\n          this.config.token,\n          this.filename,\n          this.isPublic,\n          this.gistId,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GistSyncModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with Gist\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gist\"></icon-provider>\n      </div>\n      <p>Save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synced.</p>\n      <form-entry label=\"Filename\" error=\"filename\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"filename\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <div class=\"form-entry\">\n        <div class=\"form-entry__checkbox\">\n          <label>\n            <input type=\"checkbox\" v-model=\"isPublic\"> Public\n          </label>\n        </div>\n      </div>\n      <form-entry label=\"Existing Gist ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"gistId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If the file exists in the Gist, it will be overwritten.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport gistProvider from '../../../services/providers/gistProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    filename: '',\n    gistId: '',\n  }),\n  computedLocalSettings: {\n    isPublic: 'gistIsPublic',\n  },\n  created() {\n    this.filename = `${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      if (!this.filename) {\n        this.setError('filename');\n      } else {\n        // Return new location\n        const location = gistProvider.makeLocation(\n          this.config.token,\n          this.filename,\n          this.isPublic,\n          this.gistId,\n        );\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GithubAccountModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Link GitHub account\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"github\"></icon-provider>\n      </div>\n      <p>Link your <b>GitHub</b> account to <b>StackEdit</b>.</p>\n      <div class=\"form-entry\">\n        <div class=\"form-entry__checkbox\">\n          <label>\n            <input type=\"checkbox\" v-model=\"repoFullAccess\"> Grant access to your private repositories\n          </label>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  computedLocalSettings: {\n    repoFullAccess: 'githubRepoFullAccess',\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GithubOpenModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with GitHub\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"github\"></icon-provider>\n      </div>\n      <p>Open a file from your <b>GitHub</b> repository and keep it synced.</p>\n      <form-entry label=\"Repository URL\" error=\"repoUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"repoUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://github.com/owner/my-repo\n        </div>\n      </form-entry>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> path/to/README.md\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport githubProvider from '../../../services/providers/githubProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    repoUrl: 'githubRepoUrl',\n  },\n  methods: {\n    resolve() {\n      const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);\n      if (!parsedRepo) {\n        this.setError('repoUrl');\n      }\n      if (!this.path) {\n        this.setError('path');\n      }\n      if (parsedRepo && this.path) {\n        // Return new location\n        const location = githubProvider.makeLocation(\n          this.config.token,\n          parsedRepo.owner,\n          parsedRepo.repo,\n          this.branch || 'master',\n          this.path,\n        );\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GithubPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to GitHub\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"github\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>GitHub</b> repository.</p>\n      <form-entry label=\"Repository URL\" error=\"repoUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"repoUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://github.com/owner/my-repo\n        </div>\n      </form-entry>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> path/to/README.md<br>\n          If the file exists, it will be overwritten.\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport githubProvider from '../../../services/providers/githubProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    repoUrl: 'githubRepoUrl',\n    selectedTemplate: 'githubPublishTemplate',\n  },\n  created() {\n    this.path = `${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);\n      if (!parsedRepo) {\n        this.setError('repoUrl');\n      }\n      if (!this.path) {\n        this.setError('path');\n      }\n      if (parsedRepo && this.path) {\n        // Return new location\n        const location = githubProvider.makeLocation(\n          this.config.token,\n          parsedRepo.owner,\n          parsedRepo.repo,\n          this.branch || 'master',\n          this.path,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GithubSaveModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with GitHub\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"github\"></icon-provider>\n      </div>\n      <p>Save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synced.</p>\n      <form-entry label=\"Repository URL\" error=\"repoUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"repoUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://github.com/owner/my-repo\n        </div>\n      </form-entry>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> path/to/README.md<br>\n          If the file exists, it will be overwritten.\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport githubProvider from '../../../services/providers/githubProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    repoUrl: 'githubRepoUrl',\n  },\n  created() {\n    this.path = `${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);\n      if (!parsedRepo) {\n        this.setError('repoUrl');\n      }\n      if (!this.path) {\n        this.setError('path');\n      }\n      if (parsedRepo && this.path) {\n        const location = githubProvider.makeLocation(\n          this.config.token,\n          parsedRepo.owner,\n          parsedRepo.repo,\n          this.branch || 'master',\n          this.path,\n        );\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GithubWorkspaceModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with GitHub\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"github\"></icon-provider>\n      </div>\n      <p>Create a workspace synced with a <b>GitHub</b> repository folder.</p>\n      <form-entry label=\"Repository URL\" error=\"repoUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"repoUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://github.com/owner/my-repo\n        </div>\n      </form-entry>\n      <form-entry label=\"Folder path\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the root folder will be used.\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport utils from '../../../services/utils';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    repoUrl: 'githubWorkspaceRepoUrl',\n  },\n  methods: {\n    resolve() {\n      const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);\n      if (!parsedRepo) {\n        this.setError('repoUrl');\n      } else {\n        const path = this.path && this.path.replace(/^\\//, '');\n        const url = utils.addQueryParams('app', {\n          ...parsedRepo,\n          providerId: 'githubWorkspace',\n          branch: this.branch || 'master',\n          path: path || undefined,\n        }, true);\n        this.config.resolve();\n        window.open(url);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GitlabAccountModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"GitLab account\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gitlab\"></icon-provider>\n      </div>\n      <p>Link your <b>GitLab</b> account to <b>StackEdit</b>.</p>\n      <form-entry label=\"GitLab URL\" error=\"serverUrl\">\n        <input v-if=\"config.forceServerUrl\" slot=\"field\" class=\"textfield\" type=\"text\" disabled=\"disabled\" v-model=\"config.forceServerUrl\">\n        <input v-else slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"serverUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://gitlab.example.com/\n        </div>\n      </form-entry>\n      <form-entry label=\"Application ID\" error=\"applicationId\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"applicationId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          You have to configure an OAuth2 Application with redirect URL <b>{{redirectUrl}}</b>\n        </div>\n        <div class=\"form-entry__actions\">\n          <a href=\"https://docs.gitlab.com/ee/integration/oauth_provider.html\" target=\"_blank\">More info</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\nimport constants from '../../../data/constants';\n\nexport default modalTemplate({\n  data: () => ({\n    redirectUrl: constants.oauth2RedirectUri,\n  }),\n  computedLocalSettings: {\n    serverUrl: 'gitlabServerUrl',\n    applicationId: 'gitlabApplicationId',\n  },\n  methods: {\n    resolve() {\n      const serverUrl = this.config.forceServerUrl || this.serverUrl;\n      if (!serverUrl) {\n        this.setError('serverUrl');\n      }\n      if (!this.applicationId) {\n        this.setError('applicationId');\n      }\n      if (serverUrl && this.applicationId) {\n        const parsedUrl = serverUrl.match(/^(https:\\/\\/[^/]+)/);\n        if (!parsedUrl) {\n          this.setError('serverUrl');\n        } else {\n          this.config.resolve({\n            serverUrl: parsedUrl[1],\n            applicationId: this.applicationId,\n          });\n        }\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GitlabOpenModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with GitLab\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gitlab\"></icon-provider>\n      </div>\n      <p>Open a file from your <b>GitLab</b> project and keep it synced.</p>\n      <form-entry label=\"Project URL\" error=\"projectUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"projectUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> {{config.token.serverUrl}}/path/to/project\n        </div>\n      </form-entry>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> path/to/README.md\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport gitlabProvider from '../../../services/providers/gitlabProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    projectUrl: 'gitlabProjectUrl',\n  },\n  methods: {\n    resolve() {\n      const projectPath = utils.parseGitlabProjectPath(this.projectUrl);\n      if (!projectPath) {\n        this.setError('projectUrl');\n      }\n      if (!this.path) {\n        this.setError('path');\n      }\n      if (projectPath && this.path) {\n        // Return new location\n        const location = gitlabProvider.makeLocation(\n          this.config.token,\n          projectPath,\n          this.branch || 'master',\n          this.path,\n        );\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GitlabPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to GitLab\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gitlab\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>GitLab</b> project.</p>\n      <form-entry label=\"Project URL\" error=\"projectUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"projectUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> {{config.token.serverUrl}}/path/to/project\n        </div>\n      </form-entry>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> path/to/README.md<br>\n          If the file exists, it will be overwritten.\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport gitlabProvider from '../../../services/providers/gitlabProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    projectUrl: 'gitlabProjectUrl',\n    selectedTemplate: 'gitlabPublishTemplate',\n  },\n  created() {\n    this.path = `${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      const projectPath = utils.parseGitlabProjectPath(this.projectUrl);\n      if (!projectPath) {\n        this.setError('projectUrl');\n      }\n      if (!this.path) {\n        this.setError('path');\n      }\n      if (projectPath && this.path) {\n        // Return new location\n        const location = gitlabProvider.makeLocation(\n          this.config.token,\n          projectPath,\n          this.branch || 'master',\n          this.path,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GitlabSaveModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with GitLab\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gitlab\"></icon-provider>\n      </div>\n      <p>Save <b>{{currentFileName}}</b> to your <b>GitLab</b> project and keep it synced.</p>\n      <form-entry label=\"Project URL\" error=\"projectUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"projectUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> {{config.token.serverUrl}}/path/to/project\n        </div>\n      </form-entry>\n      <form-entry label=\"File path\" error=\"path\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> path/to/README.md<br>\n          If the file exists, it will be overwritten.\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport gitlabProvider from '../../../services/providers/gitlabProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    projectUrl: 'gitlabProjectUrl',\n  },\n  created() {\n    this.path = `${this.currentFileName}.md`;\n  },\n  methods: {\n    resolve() {\n      const projectPath = utils.parseGitlabProjectPath(this.projectUrl);\n      if (!projectPath) {\n        this.setError('projectUrl');\n      }\n      if (!this.path) {\n        this.setError('path');\n      }\n      if (projectPath && this.path) {\n        const location = gitlabProvider.makeLocation(\n          this.config.token,\n          projectPath,\n          this.branch || 'master',\n          this.path,\n        );\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GitlabWorkspaceModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with GitLab\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"gitlab\"></icon-provider>\n      </div>\n      <p>Create a workspace synced with a <b>GitLab</b> project folder.</p>\n      <form-entry label=\"Project URL\" error=\"projectUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"projectUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> {{config.token.serverUrl}}/path/to/project\n        </div>\n      </form-entry>\n      <form-entry label=\"Folder path\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"path\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the root folder will be used.\n        </div>\n      </form-entry>\n      <form-entry label=\"Branch\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"branch\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the <code>master</code> branch will be used.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport utils from '../../../services/utils';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    branch: '',\n    path: '',\n  }),\n  computedLocalSettings: {\n    projectUrl: 'gitlabWorkspaceProjectUrl',\n  },\n  methods: {\n    resolve() {\n      const projectPath = utils.parseGitlabProjectPath(this.projectUrl);\n      if (!projectPath) {\n        this.setError('projectUrl');\n      } else {\n        const path = this.path && this.path.replace(/^\\//, '');\n        const url = utils.addQueryParams('app', {\n          providerId: 'gitlabWorkspace',\n          serverUrl: this.config.token.serverUrl,\n          projectPath,\n          branch: this.branch || 'master',\n          path: path || undefined,\n          sub: this.config.token.sub,\n        }, true);\n        this.config.resolve();\n        window.open(url);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GoogleDriveAccountModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Link Google Drive account\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"googleDrive\"></icon-provider>\n      </div>\n      <p>Link your <b>Google Drive</b> account to <b>StackEdit</b>.</p>\n      <div class=\"form-entry\">\n        <div class=\"form-entry__checkbox\">\n          <label>\n            <input type=\"checkbox\" v-model=\"restrictedAccess\"> Restrict access\n          </label>\n          <div class=\"form-entry__info\">\n            If checked, access will be restricted to files that you have opened or created with <b>StackEdit</b>.\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"config.resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  computedLocalSettings: {\n    restrictedAccess: 'googleDriveRestrictedAccess',\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GoogleDrivePublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to Google Drive\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"googleDrive\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>Google Drive</b> account.</p>\n      <form-entry label=\"Folder ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"folderId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the file will be created in your Drive root folder.\n        </div>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"openFolder\">Choose folder</a>\n        </div>\n      </form-entry>\n      <form-entry label=\"Existing file ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"fileId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          This will overwrite the file on the server.\n        </div>\n      </form-entry>\n      <div class=\"form-entry\">\n        <div class=\"form-entry__radio\">\n          <label>\n            <input type=\"radio\" v-model=\"format\" value=\"markdown\"> Export Markdown\n          </label>\n        </div>\n        <div class=\"form-entry__radio\">\n          <label>\n            <input type=\"radio\" v-model=\"format\" value=\"html\"> Export HTML\n          </label>\n        </div>\n      </div>\n      <form-entry label=\"Template\" v-if=\"format === 'html'\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n      <div class=\"modal__info\">\n        <b>ProTip:</b> You can provide a value for <code>title</code> in the <a href=\"javascript:void(0)\" @click=\"openFileProperties\">file properties</a>.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport googleHelper from '../../../services/providers/helpers/googleHelper';\nimport googleDriveProvider from '../../../services/providers/googleDriveProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport store from '../../../store';\n\nexport default modalTemplate({\n  data: () => ({\n    fileId: '',\n  }),\n  computedLocalSettings: {\n    folderId: 'googleDriveFolderId',\n    selectedTemplate: 'googleDrivePublishTemplate',\n    format: 'googleDrivePublishFormat',\n  },\n  methods: {\n    openFolder() {\n      return store.dispatch(\n        'modal/hideUntil',\n        googleHelper.openPicker(this.config.token, 'folder')\n          .then((folders) => {\n            if (folders[0]) {\n              store.dispatch('data/patchLocalSettings', {\n                googleDriveFolderId: folders[0].id,\n              });\n            }\n          }),\n      );\n    },\n    resolve() {\n      // Return new location\n      const location = googleDriveProvider.makeLocation(this.config.token, this.fileId);\n      if (this.format === 'html') {\n        location.templateId = this.selectedTemplate;\n      }\n      this.config.resolve(location);\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GoogleDriveSaveModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Synchronize with Google Drive\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"googleDrive\"></icon-provider>\n      </div>\n      <p>Save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synced.</p>\n      <form-entry label=\"Folder ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"folderId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, the file will be created in your Drive root folder.\n        </div>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"openFolder\">Choose folder</a>\n        </div>\n      </form-entry>\n      <form-entry label=\"Existing file ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"fileId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          This will overwrite the file on the server.\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport googleHelper from '../../../services/providers/helpers/googleHelper';\nimport googleDriveProvider from '../../../services/providers/googleDriveProvider';\nimport modalTemplate from '../common/modalTemplate';\nimport store from '../../../store';\n\nexport default modalTemplate({\n  data: () => ({\n    fileId: '',\n  }),\n  computedLocalSettings: {\n    folderId: 'googleDriveFolderId',\n  },\n  methods: {\n    openFolder() {\n      return store.dispatch(\n        'modal/hideUntil',\n        googleHelper.openPicker(this.config.token, 'folder')\n          .then((folders) => {\n            if (folders[0]) {\n              store.dispatch('data/patchLocalSettings', {\n                googleDriveFolderId: folders[0].id,\n              });\n            }\n          }),\n      );\n    },\n    resolve() {\n      // Return new location\n      const location = googleDriveProvider.makeLocation(\n        this.config.token,\n        this.fileId,\n        this.folderId,\n      );\n      this.config.resolve(location);\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GoogleDriveWorkspaceModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Add Google Drive workspace\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"googleDrive\"></icon-provider>\n      </div>\n      <p>Create a workspace synced with a <b>Google Drive</b> folder.</p>\n      <form-entry label=\"Folder ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"folderId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          If not supplied, a new workspace folder will be created in your Drive root folder.\n        </div>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"openFolder\">Choose folder</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport googleHelper from '../../../services/providers/helpers/googleHelper';\nimport modalTemplate from '../common/modalTemplate';\nimport utils from '../../../services/utils';\nimport store from '../../../store';\n\nexport default modalTemplate({\n  computedLocalSettings: {\n    folderId: 'googleDriveWorkspaceFolderId',\n  },\n  methods: {\n    openFolder() {\n      return store.dispatch(\n        'modal/hideUntil',\n        googleHelper.openPicker(this.config.token, 'folder')\n          .then((folders) => {\n            if (folders[0]) {\n              store.dispatch('data/patchLocalSettings', {\n                googleDriveWorkspaceFolderId: folders[0].id,\n              });\n            }\n          }),\n      );\n    },\n    resolve() {\n      const url = utils.addQueryParams('app', {\n        providerId: 'googleDriveWorkspace',\n        folderId: this.folderId,\n        sub: this.config.token.sub,\n      }, true);\n      this.config.resolve();\n      window.open(url);\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/GooglePhotoModal.vue",
    "content": "<template>\n  <modal-inner class=\"modal__inner-1--google-photo\" aria-label=\"Import Google Photo\">\n    <div class=\"modal__content\">\n      <div class=\"google-photo__tumbnail\" :style=\"{backgroundImage: thumbnailUrl}\"></div>\n      <form-entry label=\"Title\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"title\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <form-entry label=\"Size limit\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"size\" @keydown.enter=\"resolve()\">\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport { mapGetters } from 'vuex';\nimport ModalInner from '../common/ModalInner';\nimport FormEntry from '../common/FormEntry';\n\nconst makeThumbnail = (url, size) => `${url}=s${size}`;\n\nexport default {\n  components: {\n    ModalInner,\n    FormEntry,\n  },\n  data: () => ({\n    title: '',\n    size: '',\n  }),\n  computed: {\n    thumbnailUrl() {\n      return `url(${makeThumbnail(this.config.url, 320)})`;\n    },\n    ...mapGetters('modal', [\n      'config',\n    ]),\n  },\n  methods: {\n    resolve() {\n      let { url } = this.config;\n      const size = parseInt(this.size, 10);\n      if (!Number.isNaN(size)) {\n        url = makeThumbnail(url, size);\n      }\n      if (this.title) {\n        url += ` \"${this.title}\"`;\n      }\n      const { callback } = this.config;\n      this.config.resolve();\n      callback(url);\n    },\n    reject() {\n      const { callback } = this.config;\n      this.config.reject();\n      callback(null);\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.google-photo__tumbnail {\n  height: 160px;\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: contain;\n}\n</style>\n"
  },
  {
    "path": "src/components/modals/providers/WordpressPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to WordPress\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"wordpress\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>WordPress</b> site.</p>\n      <form-entry label=\"Site domain\" error=\"domain\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"domain\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> example.wordpress.com<br>\n          <b>Note:</b> Jetpack is required for self-hosted sites.\n        </div>\n      </form-entry>\n      <form-entry label=\"Existing post ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"postId\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n      <div class=\"modal__info\">\n        <b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code>,\n        <code>categories</code>, <code>excerpt</code>, <code>author</code>, <code>featuredImage</code>,\n        <code>status</code> and <code>date</code> in the <a href=\"javascript:void(0)\" @click=\"openFileProperties\">file properties</a>.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport wordpressProvider from '../../../services/providers/wordpressProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    postId: '',\n  }),\n  computedLocalSettings: {\n    domain: 'wordpressDomain',\n    selectedTemplate: 'wordpressPublishTemplate',\n  },\n  methods: {\n    resolve() {\n      if (!this.domain) {\n        this.setError('domain');\n      } else {\n        // Return new location\n        const location = wordpressProvider.makeLocation(\n          this.config.token,\n          this.domain,\n          this.postId,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/ZendeskAccountModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Link Zendesk account\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"zendesk\"></icon-provider>\n      </div>\n      <p>Link your <b>Zendesk</b> account to <b>StackEdit</b>.</p>\n      <form-entry label=\"Site URL\" error=\"siteUrl\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"siteUrl\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Example:</b> https://example.zendesk.com/\n        </div>\n      </form-entry>\n      <form-entry label=\"Client Unique Identifier\" error=\"clientId\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"clientId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          You have to configure an OAuth Client with redirect URL <b>{{redirectUrl}}</b>\n        </div>\n        <div class=\"form-entry__actions\">\n          <a href=\"https://support.zendesk.com/hc/en-us/articles/203663836\" target=\"_blank\">More info</a>\n        </div>\n      </form-entry>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport modalTemplate from '../common/modalTemplate';\nimport constants from '../../../data/constants';\n\nexport default modalTemplate({\n  data: () => ({\n    redirectUrl: constants.oauth2RedirectUri,\n  }),\n  computedLocalSettings: {\n    siteUrl: 'zendeskSiteUrl',\n    clientId: 'zendeskClientId',\n  },\n  methods: {\n    resolve() {\n      if (!this.siteUrl) {\n        this.setError('siteUrl');\n      }\n      if (!this.clientId) {\n        this.setError('clientId');\n      }\n      if (this.siteUrl && this.clientId) {\n        const parsedUrl = this.siteUrl.match(/^https:\\/\\/([^.]+)\\.zendesk\\.com/);\n        if (!parsedUrl) {\n          this.setError('siteUrl');\n        } else {\n          this.config.resolve({\n            subdomain: parsedUrl[1],\n            clientId: this.clientId,\n          });\n        }\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/components/modals/providers/ZendeskPublishModal.vue",
    "content": "<template>\n  <modal-inner aria-label=\"Publish to Zendesk\">\n    <div class=\"modal__content\">\n      <div class=\"modal__image\">\n        <icon-provider provider-id=\"zendesk\"></icon-provider>\n      </div>\n      <p>Publish <b>{{currentFileName}}</b> to your <b>Zendesk Help Center</b>.</p>\n      <form-entry label=\"Section ID\" error=\"sectionId\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"sectionId\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          https://example.zendesk.com/hc/en-us/sections/<b>21857469</b>-Section-name\n        </div>\n      </form-entry>\n      <form-entry label=\"Existing article ID\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"articleId\" @keydown.enter=\"resolve()\">\n      </form-entry>\n      <form-entry label=\"Locale\" info=\"optional\">\n        <input slot=\"field\" class=\"textfield\" type=\"text\" v-model.trim=\"locale\" @keydown.enter=\"resolve()\">\n        <div class=\"form-entry__info\">\n          <b>Default:</b> en-us\n        </div>\n      </form-entry>\n      <form-entry label=\"Template\">\n        <select slot=\"field\" class=\"textfield\" v-model=\"selectedTemplate\" @keydown.enter=\"resolve()\">\n          <option v-for=\"(template, id) in allTemplatesById\" :key=\"id\" :value=\"id\">\n            {{ template.name }}\n          </option>\n        </select>\n        <div class=\"form-entry__actions\">\n          <a href=\"javascript:void(0)\" @click=\"configureTemplates\">Configure templates</a>\n        </div>\n      </form-entry>\n      <div class=\"modal__info\">\n        <b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code> and\n        <code>status</code> in the <a href=\"javascript:void(0)\" @click=\"openFileProperties\">file properties</a>.\n      </div>\n    </div>\n    <div class=\"modal__button-bar\">\n      <button class=\"button\" @click=\"config.reject()\">Cancel</button>\n      <button class=\"button button--resolve\" @click=\"resolve()\">Ok</button>\n    </div>\n  </modal-inner>\n</template>\n\n<script>\nimport zendeskProvider from '../../../services/providers/zendeskProvider';\nimport modalTemplate from '../common/modalTemplate';\n\nexport default modalTemplate({\n  data: () => ({\n    articleId: '',\n  }),\n  computedLocalSettings: {\n    sectionId: 'zendescPublishSectionId',\n    locale: 'zendescPublishLocale',\n    selectedTemplate: 'zendeskPublishTemplate',\n  },\n  methods: {\n    resolve() {\n      if (!this.sectionId && !this.articleId) {\n        this.setError('sectionId');\n      } else {\n        // Return new location\n        const location = zendeskProvider.makeLocation(\n          this.config.token,\n          this.sectionId,\n          this.locale || 'en-us',\n          this.articleId,\n        );\n        location.templateId = this.selectedTemplate;\n        this.config.resolve(location);\n      }\n    },\n  },\n});\n</script>\n"
  },
  {
    "path": "src/data/constants.js",
    "content": "const origin = `${window.location.protocol}//${window.location.host}`;\n\nexport default {\n  cleanTrashAfter: 7 * 24 * 60 * 60 * 1000, // 7 days\n  origin,\n  oauth2RedirectUri: `${origin}/oauth2/callback`,\n  types: [\n    'contentState',\n    'syncedContent',\n    'content',\n    'file',\n    'folder',\n    'syncLocation',\n    'publishLocation',\n    'data',\n  ],\n  localStorageDataIds: [\n    'workspaces',\n    'settings',\n    'layoutSettings',\n    'tokens',\n    'badgeCreations',\n    'serverConf',\n  ],\n  textMaxLength: 250000,\n  defaultName: 'Untitled',\n};\n"
  },
  {
    "path": "src/data/defaults/defaultLayoutSettings.js",
    "content": "export default () => ({\n  showNavigationBar: true,\n  showEditor: true,\n  showSidePreview: true,\n  showStatusBar: true,\n  showSideBar: false,\n  showExplorer: false,\n  scrollSync: true,\n  focusMode: false,\n  findCaseSensitive: false,\n  findUseRegexp: false,\n  sideBarPanel: 'menu',\n  welcomeTourFinished: false,\n});\n"
  },
  {
    "path": "src/data/defaults/defaultLocalSettings.js",
    "content": "export default () => ({\n  welcomeFileHashes: {},\n  filePropertiesTab: '',\n  htmlExportTemplate: 'styledHtml',\n  pdfExportTemplate: 'styledHtml',\n  pandocExportFormat: 'pdf',\n  googleDriveRestrictedAccess: false,\n  googleDriveFolderId: '',\n  googleDriveWorkspaceFolderId: '',\n  googleDrivePublishFormat: 'markdown',\n  googleDrivePublishTemplate: 'styledHtml',\n  bloggerBlogUrl: '',\n  bloggerPublishTemplate: 'plainHtml',\n  dropboxRestrictedAccess: false,\n  dropboxPublishTemplate: 'styledHtml',\n  githubRepoFullAccess: false,\n  githubRepoUrl: '',\n  githubWorkspaceRepoUrl: '',\n  githubPublishTemplate: 'jekyllSite',\n  gistIsPublic: false,\n  gistPublishTemplate: 'plainText',\n  gitlabServerUrl: '',\n  gitlabApplicationId: '',\n  gitlabProjectUrl: '',\n  gitlabWorkspaceProjectUrl: '',\n  gitlabPublishTemplate: 'plainText',\n  wordpressDomain: '',\n  wordpressPublishTemplate: 'plainHtml',\n  zendeskSiteUrl: '',\n  zendeskClientId: '',\n  zendescPublishSectionId: '',\n  zendescPublishLocale: '',\n  zendeskPublishTemplate: 'plainHtml',\n});\n"
  },
  {
    "path": "src/data/defaults/defaultSettings.yml",
    "content": "# light or dark\ncolorTheme: light\n# Adjust font size in editor and preview\nfontSizeFactor: 1\n# Adjust maximum text width in editor and preview\nmaxWidthFactor: 1\n# Auto-sync frequency (in ms). Minimum is 60000.\nautoSyncEvery: 90000\n\n# Editor settings\neditor:\n  # Automatic list numbering\n  listAutoNumber: true\n  # Display images in the editor\n  inlineImages: true\n  # Use monospaced font only\n  monospacedFontOnly: false\n\n# Keyboard shortcuts\n# See https://craig.is/killing/mice\nshortcuts:\n  mod+s: sync\n  mod+f: find\n  mod+alt+f: replace\n  mod+g: replace\n  mod+shift+b: bold\n  mod+shift+c: clist\n  mod+shift+k: code\n  mod+shift+h: heading\n  mod+shift+r: hr\n  mod+shift+g: image\n  mod+shift+i: italic\n  mod+shift+l: link\n  mod+shift+o: olist\n  mod+shift+q: quote\n  mod+shift+s: strikethrough\n  mod+shift+t: table\n  mod+shift+u: ulist\n  '= = > space':\n    method: expand\n    params:\n    - '==> '\n    - '⇒ '\n  '< = = space':\n    method: expand\n    params:\n    - '<== '\n    - '⇐ '\n\n# Options passed to wkhtmltopdf\n# See https://wkhtmltopdf.org/usage/wkhtmltopdf.txt\nwkhtmltopdf:\n  marginTop: 25\n  marginRight: 25\n  marginBottom: 25\n  marginLeft: 25\n  # A3, A4, Legal or Letter\n  pageSize: A4\n\n# Options passed to pandoc\n# See https://pandoc.org/MANUAL.html\npandoc:\n  highlightStyle: kate\n  toc: true\n  tocDepth: 3\n\n# HTML to Markdown converter options\n# See https://github.com/domchristie/turndown\nturndown:\n  headingStyle: atx\n  hr: ----------\n  bulletListMarker: '-'\n  codeBlockStyle: fenced\n  fence: '```'\n  emDelimiter: _\n  strongDelimiter: '**'\n  linkStyle: inlined\n  linkReferenceStyle: full\n\n# GitHub/GitLab commit messages\ngit:\n  createFileMessage: '{{path}} created from https://stackedit.io/'\n  updateFileMessage: '{{path}} updated from https://stackedit.io/'\n  deleteFileMessage: '{{path}} deleted from https://stackedit.io/'\n\n# Default content for new files\nnewFileContent: |\n\n\n\n  > Written with [StackEdit](https://stackedit.io/).\n\n# Default properties for new files\nnewFileProperties: |\n#  extensions:\n#    preset: gfm\n\n"
  },
  {
    "path": "src/data/defaults/defaultWorkspaces.js",
    "content": "export default () => ({\n  main: {\n    id: 'main',\n    name: 'Main workspace',\n    // The rest will be filled by the workspace/workspacesById getter\n  },\n});\n"
  },
  {
    "path": "src/data/empties/emptyContent.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'content',\n  text: '\\n',\n  properties: '\\n',\n  discussions: {},\n  comments: {},\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptyContentState.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'contentState',\n  selectionStart: 0,\n  selectionEnd: 0,\n  scrollPosition: null,\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptyFile.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'file',\n  name: '',\n  parentId: null,\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptyFolder.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'folder',\n  name: '',\n  parentId: null,\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptyPublishLocation.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'publishLocation',\n  providerId: null,\n  fileId: null,\n  templateId: null,\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptySyncLocation.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'syncLocation',\n  providerId: null,\n  fileId: null,\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptySyncedContent.js",
    "content": "export default (id = null) => ({\n  id,\n  type: 'syncedContent',\n  historyData: {},\n  syncHistory: {},\n  v: 0,\n  hash: 0,\n});\n"
  },
  {
    "path": "src/data/empties/emptyTemplateHelpers.js",
    "content": "/* Add your custom Handlebars helpers here.\n\nFor example:\n\nHandlebars.registerHelper('transform', function (options) {\n  var result = options.fn(this);\n  return new Handlebars.SafeString(\n    result.replace(/<pre[^>]*>/g, '<pre class=\"prettyprint\">')\n  );\n});\n\nThen use the helper in your template:\n\n{{#transform}}{{{files.0.content.html}}}{{/transform}}\n*/\n\n"
  },
  {
    "path": "src/data/empties/emptyTemplateValue.html",
    "content": "<!-- Specify your Handlebars template here.\n\nThe following JavaScript context will be passed to the template:\n\n{\n  files: [{\n    name: 'The filename',\n    content: {\n      text: 'The file content',\n      html: '<p>The file content</p>',\n      yamlProperties: 'The file properties in YAML format',\n      properties: {\n        // Computed file properties object\n      },\n      toc: [\n        // Table Of Contents tree\n      ]\n    }\n  }]\n}\n\n\nAs an example:\n\n<html><body>{{{files.0.content.html}}}</body></html>\n\nwill produce:\n\n<html><body><p>The file content</p></body></html>\n\n\nYou can use Handlebars built-in helpers and the custom StackEdit ones:\n\n{{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a clickable TOC.\n\n{{#tocToHtml files.0.content.toc 3}}{{/tocToHtml}} will limit the TOC depth to 3.\n-->\n\n"
  },
  {
    "path": "src/data/faq.md",
    "content": "**Where is my data stored?**\n\nIf your workspace is not synced, your files are stored inside your browser and nowhere else.\n\nWe recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared. Self-hosted CouchDB or GitLab backends are well suited for privacy.\n\n**Can StackEdit access my data without telling me?**\n\nStackEdit is a browser-based application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any kind of backend or 3^rd^ party so your data won't be accessed by anyone.\n"
  },
  {
    "path": "src/data/features.js",
    "content": "class Feature {\n  constructor(id, badgeName, description, children = null) {\n    this.id = id;\n    this.badgeName = badgeName;\n    this.description = description;\n    this.children = children;\n  }\n\n  toBadge(badgeCreations) {\n    const children = this.children\n      ? this.children.map(child => child.toBadge(badgeCreations))\n      : null;\n    return {\n      featureId: this.id,\n      name: this.badgeName,\n      description: this.description,\n      children,\n      isEarned: children\n        ? children.every(child => child.isEarned)\n        : !!badgeCreations[this.id],\n      hasSomeEarned: children && children.some(child => child.isEarned),\n    };\n  }\n}\n\nexport default [\n  new Feature(\n    'navigationBar',\n    'Nav bar expert',\n    'Master the navigation bar by formatting some Markdown and renaming the current file.',\n    [\n      new Feature(\n        'formatButtons',\n        'Formatter',\n        'Use the format buttons to change formatting in your Markdown file.',\n      ),\n      new Feature(\n        'editCurrentFileName',\n        'Renamer',\n        'Use the name field in the navigation bar to rename the current file.',\n      ),\n      new Feature(\n        'toggleExplorer',\n        'Explorer toggler',\n        'Use the navigation bar to toggle the explorer.',\n      ),\n      new Feature(\n        'toggleSideBar',\n        'Side bar toggler',\n        'Use the navigation bar to toggle the side bar.',\n      ),\n    ],\n  ),\n  new Feature(\n    'explorer',\n    'Explorer',\n    'Use the file explorer to manage files and folders in your workspace.',\n    [\n      new Feature(\n        'createFile',\n        'File creator',\n        'Use the file explorer to create a new file in your workspace.',\n      ),\n      new Feature(\n        'switchFile',\n        'File switcher',\n        'Use the file explorer to switch from one file to another in your workspace.',\n      ),\n      new Feature(\n        'createFolder',\n        'Folder creator',\n        'Use the file explorer to create a new folder in your workspace.',\n      ),\n      new Feature(\n        'moveFile',\n        'File mover',\n        'Drag a file in the file explorer to move it in another folder.',\n      ),\n      new Feature(\n        'moveFolder',\n        'Folder mover',\n        'Drag a folder in the file explorer to move it in another folder.',\n      ),\n      new Feature(\n        'renameFile',\n        'File renamer',\n        'Use the file explorer to rename a file in your workspace.',\n      ),\n      new Feature(\n        'renameFolder',\n        'Folder renamer',\n        'Use the file explorer to rename a folder in your workspace.',\n      ),\n      new Feature(\n        'removeFile',\n        'File remover',\n        'Use the file explorer to remove a file in your workspace.',\n      ),\n      new Feature(\n        'removeFolder',\n        'Folder remover',\n        'Use the file explorer to remove a folder in your workspace.',\n      ),\n    ],\n  ),\n  new Feature(\n    'buttonBar',\n    'Button bar expert',\n    'Use the button bar to customize the editor layout and to toggle features.',\n    [\n      new Feature(\n        'toggleNavigationBar',\n        'Navigation bar toggler',\n        'Use the button bar to toggle the navigation bar.',\n      ),\n      new Feature(\n        'toggleSidePreview',\n        'Side preview toggler',\n        'Use the button bar to toggle the side preview.',\n      ),\n      new Feature(\n        'toggleEditor',\n        'Editor toggler',\n        'Use the button bar to toggle the editor.',\n      ),\n      new Feature(\n        'toggleFocusMode',\n        'Focused',\n        'Use the button bar to toggle the focus mode. This mode keeps the caret vertically centered while typing.',\n      ),\n      new Feature(\n        'toggleScrollSync',\n        'Scroll sync toggler',\n        'Use the button bar to toggle the scroll sync feature. This feature links the editor and the preview scrollbars.',\n      ),\n      new Feature(\n        'toggleStatusBar',\n        'Status bar toggler',\n        'Use the button bar to toggle the status bar.',\n      ),\n    ],\n  ),\n  new Feature(\n    'signIn',\n    'Signed in',\n    'Sign in with Google, sync your main workspace and unlock functionalities.',\n    [\n      new Feature(\n        'syncMainWorkspace',\n        'Main workspace synced',\n        'Sign in with Google to sync your main workspace with your Google Drive app data folder.',\n      ),\n      new Feature(\n        'sponsor',\n        'Sponsor',\n        'Sign in with Google and sponsor StackEdit to unlock PDF and Pandoc exports.',\n      ),\n    ],\n  ),\n  new Feature(\n    'workspaces',\n    'Workspace expert',\n    'Use the workspace menu to create all kinds of workspaces and to manage them.',\n    [\n      new Feature(\n        'addCouchdbWorkspace',\n        'CouchDB workspace creator',\n        'Use the workspace menu to create a CouchDB workspace.',\n      ),\n      new Feature(\n        'addGithubWorkspace',\n        'GitHub workspace creator',\n        'Use the workspace menu to create a GitHub workspace.',\n      ),\n      new Feature(\n        'addGitlabWorkspace',\n        'GitLab workspace creator',\n        'Use the workspace menu to create a GitLab workspace.',\n      ),\n      new Feature(\n        'addGoogleDriveWorkspace',\n        'Google Drive workspace creator',\n        'Use the workspace menu to create a Google Drive workspace.',\n      ),\n      new Feature(\n        'renameWorkspace',\n        'Workspace renamer',\n        'Use the \"Manage workspaces\" dialog to rename a workspace.',\n      ),\n      new Feature(\n        'removeWorkspace',\n        'Workspace remover',\n        'Use the \"Manage workspaces\" dialog to remove a workspace locally.',\n      ),\n    ],\n  ),\n  new Feature(\n    'manageAccounts',\n    'Account manager',\n    'Link all kinds of external accounts and use the \"Accounts\" dialog to manage them.',\n    [\n      new Feature(\n        'addBloggerAccount',\n        'Blogger user',\n        'Link your Blogger account to StackEdit.',\n      ),\n      new Feature(\n        'addDropboxAccount',\n        'Dropbox user',\n        'Link your Dropbox account to StackEdit.',\n      ),\n      new Feature(\n        'addGitHubAccount',\n        'GitHub user',\n        'Link your GitHub account to StackEdit.',\n      ),\n      new Feature(\n        'addGitLabAccount',\n        'GitLab user',\n        'Link your GitLab account to StackEdit.',\n      ),\n      new Feature(\n        'addGoogleDriveAccount',\n        'Google Drive user',\n        'Link your Google Drive account to StackEdit.',\n      ),\n      new Feature(\n        'addGooglePhotosAccount',\n        'Google Photos user',\n        'Link your Google Photos account to StackEdit.',\n      ),\n      new Feature(\n        'addWordpressAccount',\n        'WordPress user',\n        'Link your WordPress account to StackEdit.',\n      ),\n      new Feature(\n        'addZendeskAccount',\n        'Zendesk user',\n        'Link your Zendesk account to StackEdit.',\n      ),\n      new Feature(\n        'removeAccount',\n        'Revoker',\n        'Use the \"Accounts\" dialog to remove access to an external account.',\n      ),\n    ],\n  ),\n  new Feature(\n    'syncFiles',\n    'File synchronizer',\n    'Master the \"Synchronize\" menu by opening and saving files with all kinds of external accounts.',\n    [\n      new Feature(\n        'openFromDropbox',\n        'Dropbox reader',\n        'Use the \"Synchronize\" menu to open a file from your Dropbox account.',\n      ),\n      new Feature(\n        'saveOnDropbox',\n        'Dropbox writer',\n        'Use the \"Synchronize\" menu to save a file in your Dropbox account.',\n      ),\n      new Feature(\n        'openFromGithub',\n        'GitHub reader',\n        'Use the \"Synchronize\" menu to open a file from a GitHub repository.',\n      ),\n      new Feature(\n        'saveOnGithub',\n        'GitHub writer',\n        'Use the \"Synchronize\" menu to save a file in a GitHub repository.',\n      ),\n      new Feature(\n        'saveOnGist',\n        'Gist writer',\n        'Use the \"Synchronize\" menu to save a file in a Gist.',\n      ),\n      new Feature(\n        'openFromGitlab',\n        'GitLab reader',\n        'Use the \"Synchronize\" menu to open a file from a GitLab repository.',\n      ),\n      new Feature(\n        'saveOnGitlab',\n        'GitLab writer',\n        'Use the \"Synchronize\" menu to save a file in a GitLab repository.',\n      ),\n      new Feature(\n        'openFromGoogleDrive',\n        'Google Drive reader',\n        'Use the \"Synchronize\" menu to open a file from your Google Drive account.',\n      ),\n      new Feature(\n        'saveOnGoogleDrive',\n        'Google Drive writer',\n        'Use the \"Synchronize\" menu to save a file in your Google Drive account.',\n      ),\n      new Feature(\n        'triggerSync',\n        'Sync trigger',\n        'Use the \"Synchronize\" menu or the navigation bar to manually trigger synchronization.',\n      ),\n      new Feature(\n        'syncMultipleLocations',\n        'Multi-sync',\n        'Use the \"Synchronize\" menu to synchronize a file with multiple external locations.',\n      ),\n      new Feature(\n        'removeSyncLocation',\n        'Desynchronizer',\n        'Use the \"File synchronization\" dialog to remove a sync location.',\n      ),\n    ],\n  ),\n  new Feature(\n    'publishFiles',\n    'File publisher',\n    'Master the \"Publish\" menu by publishing files to all kinds of external accounts.',\n    [\n      new Feature(\n        'publishToBlogger',\n        'Blogger publisher',\n        'Use the \"Publish\" menu to publish a Blogger article.',\n      ),\n      new Feature(\n        'publishToBloggerPage',\n        'Blogger Page publisher',\n        'Use the \"Publish\" menu to publish a Blogger page.',\n      ),\n      new Feature(\n        'publishToDropbox',\n        'Dropbox publisher',\n        'Use the \"Publish\" menu to publish a file to your Dropbox account.',\n      ),\n      new Feature(\n        'publishToGithub',\n        'GitHub publisher',\n        'Use the \"Publish\" menu to publish a file to a GitHub repository.',\n      ),\n      new Feature(\n        'publishToGist',\n        'Gist publisher',\n        'Use the \"Publish\" menu to publish a file to a Gist.',\n      ),\n      new Feature(\n        'publishToGitlab',\n        'GitLab publisher',\n        'Use the \"Publish\" menu to publish a file to a GitLab repository.',\n      ),\n      new Feature(\n        'publishToGoogleDrive',\n        'Google Drive publisher',\n        'Use the \"Publish\" menu to publish a file to your Google Drive account.',\n      ),\n      new Feature(\n        'publishToWordPress',\n        'WordPress publisher',\n        'Use the \"Publish\" menu to publish a WordPress article.',\n      ),\n      new Feature(\n        'publishToZendesk',\n        'Zendesk publisher',\n        'Use the \"Publish\" menu to publish a Zendesk Help Center article.',\n      ),\n      new Feature(\n        'triggerPublish',\n        'Publication reviser',\n        'Use the \"Publish\" menu or the navigation bar to manually update publications.',\n      ),\n      new Feature(\n        'publishMultipleLocations',\n        'Multi-publication',\n        'Use the \"Publish\" menu to publish a file to multiple external locations.',\n      ),\n      new Feature(\n        'removePublishLocation',\n        'Unpublisher',\n        'Use the \"File publication\" dialog to remove a publish location.',\n      ),\n    ],\n  ),\n  new Feature(\n    'manageHistory',\n    'Historian',\n    'Use the \"File history\" menu to see version history and restore old versions of the current file.',\n    [\n      new Feature(\n        'restoreVersion',\n        'Restorer',\n        'Use the \"File history\" menu to restore an old version of the current file.',\n      ),\n      new Feature(\n        'chooseHistory',\n        'History chooser',\n        'Select a different history for a file that is synced with multiple external locations.',\n      ),\n    ],\n  ),\n  new Feature(\n    'manageProperties',\n    'Property expert',\n    'Use the \"File properties\" dialog to change properties for the current file.',\n    [\n      new Feature(\n        'setMetadata',\n        'Metadata setter',\n        'Use the \"File properties\" dialog to set metadata for the current file.',\n      ),\n      new Feature(\n        'changePreset',\n        'Preset changer',\n        'Use the \"File properties\" dialog to change the Markdown engine preset.',\n      ),\n      new Feature(\n        'changeExtension',\n        'Extension expert',\n        'Use the \"File properties\" dialog to enable, disable or configure Markdown engine extensions.',\n      ),\n    ],\n  ),\n  new Feature(\n    'comment',\n    'Comment expert',\n    'Start and remove discussions, add and remove comments.',\n    [\n      new Feature(\n        'createDiscussion',\n        'Discussion starter',\n        'Use the \"comment\" button to start a new discussion.',\n      ),\n      new Feature(\n        'addComment',\n        'Commenter',\n        'Use the discussion gutter to add a comment to an existing discussion.',\n      ),\n      new Feature(\n        'removeComment',\n        'Moderator',\n        'Use the discussion gutter to remove a comment in a discussion.',\n      ),\n      new Feature(\n        'removeDiscussion',\n        'Discussion closer',\n        'Use the discussion gutter to remove a discussion.',\n      ),\n    ],\n  ),\n  new Feature(\n    'importExport',\n    'Import/export',\n    'Use the \"Import/export\" menu to import and export files.',\n    [\n      new Feature(\n        'importMarkdown',\n        'Markdown importer',\n        'Use the \"Import/export\" menu to import a Markdown file from disk.',\n      ),\n      new Feature(\n        'exportMarkdown',\n        'Markdown exporter',\n        'Use the \"Import/export\" menu to export a Markdown file to disk.',\n      ),\n      new Feature(\n        'importHtml',\n        'HTML importer',\n        'Use the \"Import/export\" menu to import an HTML file from disk and convert it to Markdown.',\n      ),\n      new Feature(\n        'exportHtml',\n        'HTML exporter',\n        'Use the \"Import/export\" menu to export a file to disk as an HTML file using a Handlebars template.',\n      ),\n      new Feature(\n        'exportPdf',\n        'PDF exporter',\n        'Use the \"Import/export\" menu to export a file to disk as a PDF file.',\n      ),\n      new Feature(\n        'exportPandoc',\n        'Pandoc exporter',\n        'Use the \"Import/export\" menu to export a file to disk using Pandoc.',\n      ),\n    ],\n  ),\n  new Feature(\n    'manageSettings',\n    'Settings expert',\n    'Use the \"Settings\" dialog to tweak the application behaviors and change keyboard shortcuts.',\n    [\n      new Feature(\n        'changeSettings',\n        'Tweaker',\n        'Use the \"Settings\" dialog to tweak the application behaviors.',\n      ),\n      new Feature(\n        'changeShortcuts',\n        'Shortcut editor',\n        'Use the \"Settings\" dialog to change keyboard shortcuts.',\n      ),\n    ],\n  ),\n  new Feature(\n    'manageTemplates',\n    'Template expert',\n    'Use the \"Templates\" dialog to create, remove or modify Handlebars templates.',\n    [\n      new Feature(\n        'addTemplate',\n        'Template creator',\n        'Use the \"Templates\" dialog to create a Handlebars template.',\n      ),\n      new Feature(\n        'removeTemplate',\n        'Template remover',\n        'Use the \"Templates\" dialog to remove a Handlebars template.',\n      ),\n    ],\n  ),\n];\n"
  },
  {
    "path": "src/data/markdownSample.md",
    "content": "Headers\n---------------------------\n\n# Header 1\n\n## Header 2\n\n### Header 3\n\n\n\nStyling\n---------------------------\n\n*Emphasize* _emphasize_\n\n**Strong** __strong__\n\n==Marked text.==\n\n~~Mistaken text.~~\n\n> Quoted text.\n\nH~2~O is a liquid.\n\n2^10^ is 1024.\n\n\n\nLists\n---------------------------\n\n- Item\n  * Item\n    + Item\n\n1. Item 1\n2. Item 2\n3. Item 3\n\n- [ ] Incomplete item\n- [x] Complete item\n\n\n\nLinks\n---------------------------\n\nA [link](http://example.com).\n\nAn image: ![Alt](img.jpg)\n\nA sized image: ![Alt](img.jpg =60x50)\n\n\n\nCode\n---------------------------\n\nSome `inline code`.\n\n```\n// A code block\nvar foo = 'bar';\n```\n\n```javascript\n// An highlighted block\nvar foo = 'bar';\n```\n\n\n\nTables\n---------------------------\n\nItem     | Value\n-------- | -----\nComputer | $1600\nPhone    | $12\nPipe     | $1\n\n\n| Column 1 | Column 2      |\n|:--------:| -------------:|\n| centered | right-aligned |\n\n\n\nDefinition lists\n---------------------------\n\nMarkdown\n:  Text-to-HTML conversion tool\n\nAuthors\n:  John\n:  Luke\n\n\n\nFootnotes\n---------------------------\n\nSome text with a footnote.[^1]\n\n[^1]: The footnote.\n\n\n\nAbbreviations\n---------------------------\n\nMarkdown converts text to HTML.\n\n*[HTML]: HyperText Markup Language\n\n\n\nLaTeX math\n---------------------------\n\nThe Gamma function satisfying $\\Gamma(n) = (n-1)!\\quad\\forall\nn\\in\\mathbb N$ is via the Euler integral\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n"
  },
  {
    "path": "src/data/pagedownButtons.js",
    "content": "export default [{\n}, {\n  method: 'bold',\n  title: 'Bold',\n  icon: 'format-bold',\n}, {\n  method: 'italic',\n  title: 'Italic',\n  icon: 'format-italic',\n}, {\n  method: 'heading',\n  title: 'Heading',\n  icon: 'format-size',\n}, {\n  method: 'strikethrough',\n  title: 'Strikethrough',\n  icon: 'format-strikethrough',\n}, {\n}, {\n  method: 'ulist',\n  title: 'Unordered list',\n  icon: 'format-list-bulleted',\n}, {\n  method: 'olist',\n  title: 'Ordered list',\n  icon: 'format-list-numbers',\n}, {\n  method: 'clist',\n  title: 'Check list',\n  icon: 'format-list-checks',\n}, {\n}, {\n  method: 'quote',\n  title: 'Blockquote',\n  icon: 'format-quote-close',\n}, {\n  method: 'code',\n  title: 'Code',\n  icon: 'code-tags',\n}, {\n  method: 'table',\n  title: 'Table',\n  icon: 'table',\n}, {\n  method: 'link',\n  title: 'Link',\n  icon: 'link-variant',\n}, {\n  method: 'image',\n  title: 'Image',\n  icon: 'file-image',\n}];\n"
  },
  {
    "path": "src/data/presets.js",
    "content": "const zero = {\n  // Markdown extensions\n  markdown: {\n    abbr: false,\n    breaks: false,\n    deflist: false,\n    del: false,\n    fence: false,\n    footnote: false,\n    imgsize: false,\n    linkify: false,\n    mark: false,\n    sub: false,\n    sup: false,\n    table: false,\n    tasklist: false,\n    typographer: false,\n  },\n  // Emoji extension\n  emoji: {\n    enabled: false,\n    // Enable shortcuts like :) :-(\n    shortcuts: false,\n  },\n  /*\n  ABC Notation extension\n  Render abc-notation code blocks to music sheets\n  See https://abcjs.net/\n  */\n  abc: {\n    enabled: false,\n  },\n  /*\n  Katex extension\n  Render LaTeX mathematical expressions using:\n    $...$ for inline formulas\n    $$...$$ for displayed formulas.\n  See https://math.meta.stackexchange.com/questions/5020\n  */\n  katex: {\n    enabled: false,\n  },\n  /*\n  Mermaid extension\n  Convert code blocks starting with ```mermaid\n  into diagrams and flowcharts.\n  See https://mermaidjs.github.io/\n  */\n  mermaid: {\n    enabled: false,\n  },\n};\n\nexport default {\n  zero: [zero],\n  commonmark: [zero, {\n    markdown: {\n      fence: true,\n    },\n  }],\n  gfm: [zero, {\n    markdown: {\n      breaks: true,\n      del: true,\n      fence: true,\n      linkify: true,\n      table: true,\n      tasklist: true,\n    },\n    emoji: {\n      enabled: true,\n    },\n  }],\n  default: [zero, {\n    markdown: {\n      abbr: true,\n      breaks: true,\n      deflist: true,\n      del: true,\n      fence: true,\n      footnote: true,\n      imgsize: true,\n      linkify: true,\n      mark: true,\n      sub: true,\n      sup: true,\n      table: true,\n      tasklist: true,\n      typographer: true,\n    },\n    emoji: {\n      enabled: true,\n    },\n    katex: {\n      enabled: true,\n    },\n    mermaid: {\n      enabled: true,\n    },\n    abc: {\n      enabled: true,\n    },\n  }],\n};\n"
  },
  {
    "path": "src/data/simpleModals.js",
    "content": "const simpleModal = (contentHtml, rejectText, resolveText) => ({\n  contentHtml: typeof contentHtml === 'function' ? contentHtml : () => contentHtml,\n  rejectText,\n  resolveText,\n});\n\n/* eslint sort-keys: \"error\" */\nexport default {\n  commentDeletion: simpleModal(\n    '<p>You are about to delete a comment. Are you sure?</p>',\n    'No',\n    'Yes, delete',\n  ),\n  discussionDeletion: simpleModal(\n    '<p>You are about to delete a discussion. Are you sure?</p>',\n    'No',\n    'Yes, delete',\n  ),\n  fileRestoration: simpleModal(\n    '<p>You are about to revert some changes. Are you sure?</p>',\n    'No',\n    'Yes, revert',\n  ),\n  folderDeletion: simpleModal(\n    config => `<p>You are about to delete the folder <b>${config.item.name}</b>. Its files will be moved to Trash. Are you sure?</p>`,\n    'No',\n    'Yes, delete',\n  ),\n  pathConflict: simpleModal(\n    config => `<p><b>${config.item.name}</b> already exists. Do you want to add a suffix?</p>`,\n    'No',\n    'Yes, add suffix',\n  ),\n  paymentSuccess: simpleModal(\n    '<h3>Thank you for your payment!</h3><p>Your sponsorship will be active in a minute.</p>',\n    'Ok',\n  ),\n  providerRedirection: simpleModal(\n    config => `<p>You are about to navigate to the <b>${config.name}</b> authorization page.</p>`,\n    'Cancel',\n    'Ok, go on',\n  ),\n  removeWorkspace: simpleModal(\n    '<p>You are about to remove a workspace locally. Are you sure?</p>',\n    'No',\n    'Yes, remove',\n  ),\n  reset: simpleModal(\n    '<p>This will clean all your workspaces locally. Are you sure?</p>',\n    'No',\n    'Yes, clean',\n  ),\n  signInForComment: simpleModal(\n    `<p>You have to sign in with Google to start commenting.</p>\n    <div class=\"modal__info\"><b>Note:</b> This will sync your main workspace.</div>`,\n    'Cancel',\n    'Ok, sign in',\n  ),\n  signInForSponsorship: simpleModal(\n    `<p>You have to sign in with Google to sponsor.</p>\n    <div class=\"modal__info\"><b>Note:</b> This will sync your main workspace.</div>`,\n    'Cancel',\n    'Ok, sign in',\n  ),\n  sponsorOnly: simpleModal(\n    '<p>This feature is restricted to sponsors as it relies on server resources.</p>',\n    'Ok, I understand',\n  ),\n  stripName: simpleModal(\n    config => `<p><b>${config.item.name}</b> contains illegal characters. Do you want to strip them?</p>`,\n    'No',\n    'Yes, strip',\n  ),\n  tempFileDeletion: simpleModal(\n    config => `<p>You are about to permanently delete the temporary file <b>${config.item.name}</b>. Are you sure?</p>`,\n    'No',\n    'Yes, delete',\n  ),\n  tempFolderDeletion: simpleModal(\n    '<p>You are about to permanently delete all the temporary files. Are you sure?</p>',\n    'No',\n    'Yes, delete all',\n  ),\n  trashDeletion: simpleModal(\n    '<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',\n    'Ok',\n  ),\n  unauthorizedName: simpleModal(\n    config => `<p><b>${config.item.name}</b> is an unauthorized name.</p>`,\n    'Ok',\n  ),\n  workspaceGoogleRedirection: simpleModal(\n    '<p>StackEdit needs full Google Drive access to open this workspace.</p>',\n    'Cancel',\n    'Ok, grant',\n  ),\n};\n"
  },
  {
    "path": "src/data/templates/jekyllSiteTemplate.html",
    "content": "---\n{{{files.0.content.yamlProperties}}}\n---\n\n{{{files.0.content.html}}}\n"
  },
  {
    "path": "src/data/templates/plainHtmlTemplate.html",
    "content": "{{{files.0.content.html}}}\n"
  },
  {
    "path": "src/data/templates/styledHtmlTemplate.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>{{files.0.name}}</title>\n  <link rel=\"stylesheet\" href=\"https://stackedit.io/style.css\" />\n</head>\n\n{{#if pdf}}\n<body class=\"stackedit stackedit--pdf\">\n{{else}}\n<body class=\"stackedit\">\n{{/if}}\n  <div class=\"stackedit__html\">{{{files.0.content.html}}}</div>\n</body>\n\n</html>\n"
  },
  {
    "path": "src/data/templates/styledHtmlWithTocTemplate.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>{{files.0.name}}</title>\n  <link rel=\"stylesheet\" href=\"https://stackedit.io/style.css\" />\n</head>\n\n{{#if pdf}}\n<body class=\"stackedit stackedit--pdf\">\n{{else}}\n<body class=\"stackedit\">\n{{/if}}\n  <div class=\"stackedit__left\">\n    <div class=\"stackedit__toc\">\n      {{#tocToHtml files.0.content.toc 2}}{{/tocToHtml}}\n    </div>\n  </div>\n  <div class=\"stackedit__right\">\n    <div class=\"stackedit__html\">\n      {{{files.0.content.html}}}\n    </div>\n  </div>\n</body>\n\n</html>\n"
  },
  {
    "path": "src/data/welcomeFile.md",
    "content": "# Welcome to StackEdit!\n\nHi! I'm your first Markdown file in **StackEdit**. If you want to learn about StackEdit, you can read me. If you want to play with Markdown, you can edit me. Once you have finished with me, you can create new files by opening the **file explorer** on the left corner of the navigation bar.\n\n\n# Files\n\nStackEdit stores your files in your browser, which means all your files are automatically saved locally and are accessible **offline!**\n\n## Create files and folders\n\nThe file explorer is accessible using the button in left corner of the navigation bar. You can create a new file by clicking the **New file** button in the file explorer. You can also create folders by clicking the **New folder** button.\n\n## Switch to another file\n\nAll your files and folders are presented as a tree in the file explorer. You can switch from one to another by clicking a file in the tree.\n\n## Rename a file\n\nYou can rename the current file by clicking the file name in the navigation bar or by clicking the **Rename** button in the file explorer.\n\n## Delete a file\n\nYou can delete the current file by clicking the **Remove** button in the file explorer. The file will be moved into the **Trash** folder and automatically deleted after 7 days of inactivity.\n\n## Export a file\n\nYou can export the current file by clicking **Export to disk** in the menu. You can choose to export the file as plain Markdown, as HTML using a Handlebars template or as a PDF.\n\n\n# Synchronization\n\nSynchronization is one of the biggest features of StackEdit. It enables you to synchronize any file in your workspace with other files stored in your **Google Drive**, your **Dropbox** and your **GitHub** accounts. This allows you to keep writing on other devices, collaborate with people you share the file with, integrate easily into your workflow... The synchronization mechanism takes place every minute in the background, downloading, merging, and uploading file modifications.\n\nThere are two types of synchronization and they can complement each other:\n\n- The workspace synchronization will sync all your files, folders and settings automatically. This will allow you to fetch your workspace on any other device.\n\t> To start syncing your workspace, just sign in with Google in the menu.\n\n- The file synchronization will keep one file of the workspace synced with one or multiple files in **Google Drive**, **Dropbox** or **GitHub**.\n\t> Before starting to sync files, you must link an account in the **Synchronize** sub-menu.\n\n## Open a file\n\nYou can open a file from **Google Drive**, **Dropbox** or **GitHub** by opening the **Synchronize** sub-menu and clicking **Open from**. Once opened in the workspace, any modification in the file will be automatically synced.\n\n## Save a file\n\nYou can save any file of the workspace to **Google Drive**, **Dropbox** or **GitHub** by opening the **Synchronize** sub-menu and clicking **Save on**. Even if a file in the workspace is already synced, you can save it to another location. StackEdit can sync one file with multiple locations and accounts.\n\n## Synchronize a file\n\nOnce your file is linked to a synchronized location, StackEdit will periodically synchronize it by downloading/uploading any modification. A merge will be performed if necessary and conflicts will be resolved.\n\nIf you just have modified your file and you want to force syncing, click the **Synchronize now** button in the navigation bar.\n\n> **Note:** The **Synchronize now** button is disabled if you have no file to synchronize.\n\n## Manage file synchronization\n\nSince one file can be synced with multiple locations, you can list and manage synchronized locations by clicking **File synchronization** in the **Synchronize** sub-menu. This allows you to list and remove synchronized locations that are linked to your file.\n\n\n# Publication\n\nPublishing in StackEdit makes it simple for you to publish online your files. Once you're happy with a file, you can publish it to different hosting platforms like **Blogger**, **Dropbox**, **Gist**, **GitHub**, **Google Drive**, **WordPress** and **Zendesk**. With [Handlebars templates](http://handlebarsjs.com/), you have full control over what you export.\n\n> Before starting to publish, you must link an account in the **Publish** sub-menu.\n\n## Publish a File\n\nYou can publish your file by opening the **Publish** sub-menu and by clicking **Publish to**. For some locations, you can choose between the following formats:\n\n- Markdown: publish the Markdown text on a website that can interpret it (**GitHub** for instance),\n- HTML: publish the file converted to HTML via a Handlebars template (on a blog for example).\n\n## Update a publication\n\nAfter publishing, StackEdit keeps your file linked to that publication which makes it easy for you to re-publish it. Once you have modified your file and you want to update your publication, click on the **Publish now** button in the navigation bar.\n\n> **Note:** The **Publish now** button is disabled if your file has not been published yet.\n\n## Manage file publication\n\nSince one file can be published to multiple locations, you can list and manage publish locations by clicking **File publication** in the **Publish** sub-menu. This allows you to list and remove publication locations that are linked to your file.\n\n\n# Markdown extensions\n\nStackEdit extends the standard Markdown syntax by adding extra **Markdown extensions**, providing you with some nice features.\n\n> **ProTip:** You can disable any **Markdown extension** in the **File properties** dialog.\n\n\n## SmartyPants\n\nSmartyPants converts ASCII punctuation characters into \"smart\" typographic punctuation HTML entities. For example:\n\n|                |ASCII                          |HTML                         |\n|----------------|-------------------------------|-----------------------------|\n|Single backticks|`'Isn't this fun?'`            |'Isn't this fun?'            |\n|Quotes          |`\"Isn't this fun?\"`            |\"Isn't this fun?\"            |\n|Dashes          |`-- is en-dash, --- is em-dash`|-- is en-dash, --- is em-dash|\n\n\n## KaTeX\n\nYou can render LaTeX mathematical expressions using [KaTeX](https://khan.github.io/KaTeX/):\n\nThe *Gamma function* satisfying $\\Gamma(n) = (n-1)!\\quad\\forall n\\in\\mathbb N$ is via the Euler integral\n\n$$\n\\Gamma(z) = \\int_0^\\infty t^{z-1}e^{-t}dt\\,.\n$$\n\n> You can find more information about **LaTeX** mathematical expressions [here](http://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference).\n\n\n## UML diagrams\n\nYou can render UML diagrams using [Mermaid](https://mermaidjs.github.io/). For example, this will produce a sequence diagram:\n\n```mermaid\nsequenceDiagram\nAlice ->> Bob: Hello Bob, how are you?\nBob-->>John: How about you John?\nBob--x Alice: I am good thanks!\nBob-x John: I am good thanks!\nNote right of John: Bob thinks a long<br/>long time, so long<br/>that the text does<br/>not fit on a row.\n\nBob-->Alice: Checking with John...\nAlice->John: Yes... John, how are you?\n```\n\nAnd this will produce a flow chart:\n\n```mermaid\ngraph LR\nA[Square Rect] -- Link text --> B((Circle))\nA --> C(Round Rect)\nB --> D{Rhombus}\nC --> D\n```\n"
  },
  {
    "path": "src/extensions/abcExtension.js",
    "content": "import renderAbc from 'abcjs/src/api/abc_tunebook_svg';\nimport extensionSvc from '../services/extensionSvc';\n\nconst render = (elt) => {\n  const content = elt.textContent;\n  // Create a div element\n  const divElt = document.createElement('div');\n  divElt.className = 'abc-notation-block';\n  // Replace the pre element with the div\n  elt.parentNode.parentNode.replaceChild(divElt, elt.parentNode);\n  renderAbc(divElt, content, {});\n};\n\nextensionSvc.onGetOptions((options, properties) => {\n  options.abc = properties.extensions.abc.enabled;\n});\n\nextensionSvc.onSectionPreview((elt) => {\n  elt.querySelectorAll('.prism.language-abc')\n    .cl_each(notationElt => render(notationElt));\n});\n"
  },
  {
    "path": "src/extensions/emojiExtension.js",
    "content": "import markdownItEmoji from 'markdown-it-emoji';\nimport extensionSvc from '../services/extensionSvc';\n\nextensionSvc.onGetOptions((options, properties) => {\n  options.emoji = properties.extensions.emoji.enabled;\n  options.emojiShortcuts = properties.extensions.emoji.shortcuts;\n});\n\nextensionSvc.onInitConverter(1, (markdown, options) => {\n  if (options.emoji) {\n    markdown.use(markdownItEmoji, options.emojiShortcuts ? {} : { shortcuts: {} });\n  }\n});\n"
  },
  {
    "path": "src/extensions/index.js",
    "content": "import './emojiExtension';\nimport './abcExtension';\nimport './katexExtension';\nimport './markdownExtension';\nimport './mermaidExtension';\n"
  },
  {
    "path": "src/extensions/katexExtension.js",
    "content": "import katex from 'katex';\nimport markdownItMath from './libs/markdownItMath';\nimport extensionSvc from '../services/extensionSvc';\n\nextensionSvc.onGetOptions((options, properties) => {\n  options.math = properties.extensions.katex.enabled;\n});\n\nextensionSvc.onInitConverter(2, (markdown, options) => {\n  if (options.math) {\n    markdown.use(markdownItMath);\n    markdown.renderer.rules.inline_math = (tokens, idx) =>\n      `<span class=\"katex--inline\">${markdown.utils.escapeHtml(tokens[idx].content)}</span>`;\n    markdown.renderer.rules.display_math = (tokens, idx) =>\n      `<span class=\"katex--display\">${markdown.utils.escapeHtml(tokens[idx].content)}</span>`;\n  }\n});\n\nextensionSvc.onSectionPreview((elt) => {\n  const highlighter = displayMode => (katexElt) => {\n    if (!katexElt.highlighted) {\n      try {\n        katex.render(katexElt.textContent, katexElt, { displayMode });\n      } catch (e) {\n        katexElt.textContent = `${e.message}`;\n      }\n    }\n    katexElt.highlighted = true;\n  };\n  elt.querySelectorAll('.katex--inline').cl_each(highlighter(false));\n  elt.querySelectorAll('.katex--display').cl_each(highlighter(true));\n});\n"
  },
  {
    "path": "src/extensions/libs/markdownItAnchor.js",
    "content": "export default (md) => {\n  md.core.ruler.before('replacements', 'anchors', (state) => {\n    const anchorHash = {};\n    let headingOpenToken;\n    let headingContent;\n    state.tokens.forEach((token) => {\n      if (token.type === 'heading_open') {\n        headingContent = '';\n        headingOpenToken = token;\n      } else if (token.type === 'heading_close') {\n        headingOpenToken.headingContent = headingContent;\n\n        // According to http://pandoc.org/README.html#extension-auto_identifiers\n        let slug = headingContent\n          .replace(/\\s/g, '-') // Replace all spaces and newlines with hyphens\n          .replace(/[\\0-,/:-@[-^`{-~]/g, '') // Remove all punctuation, except underscores, hyphens, and periods\n          .toLowerCase(); // Convert all alphabetic characters to lowercase\n\n        // Remove everything up to the first letter\n        let i;\n        for (i = 0; i < slug.length; i += 1) {\n          const charCode = slug.charCodeAt(i);\n          if ((charCode >= 0x61 && charCode <= 0x7A) || charCode > 0x7E) {\n            break;\n          }\n        }\n\n        // If nothing left after this, use `section`\n        slug = slug.slice(i) || 'section';\n\n        let anchor = slug;\n        let index = 1;\n        while (Object.prototype.hasOwnProperty.call(anchorHash, anchor)) {\n          anchor = `${slug}-${index}`;\n          index += 1;\n        }\n        anchorHash[anchor] = true;\n        headingOpenToken.headingAnchor = anchor;\n        headingOpenToken.attrs = [\n          ['id', anchor],\n        ];\n        headingOpenToken = undefined;\n      } else if (headingOpenToken) {\n        headingContent += token.children.reduce((result, child) => {\n          if (child.type !== 'footnote_ref') {\n            return result + child.content;\n          }\n          return result;\n        }, '');\n      }\n    });\n  });\n};\n"
  },
  {
    "path": "src/extensions/libs/markdownItMath.js",
    "content": "function texMath(state, silent) {\n  let startMathPos = state.pos;\n  if (state.src.charCodeAt(startMathPos) !== 0x24 /* $ */) {\n    return false;\n  }\n\n  // Parse tex math according to http://pandoc.org/README.html#math\n  let endMarker = '$';\n  startMathPos += 1;\n  const afterStartMarker = state.src.charCodeAt(startMathPos);\n  if (afterStartMarker === 0x24 /* $ */) {\n    endMarker = '$$';\n    startMathPos += 1;\n    if (state.src.charCodeAt(startMathPos) === 0x24 /* $ */) {\n      // 3 markers are too much\n      return false;\n    }\n  } else if (\n    // Skip if opening $ is succeeded by a space character\n    afterStartMarker === 0x20 /* space */\n    || afterStartMarker === 0x09 /* \\t */\n    || afterStartMarker === 0x0a /* \\n */\n  ) {\n    return false;\n  }\n  const endMarkerPos = state.src.indexOf(endMarker, startMathPos);\n  if (endMarkerPos === -1) {\n    return false;\n  }\n  if (state.src.charCodeAt(endMarkerPos - 1) === 0x5C /* \\ */) {\n    return false;\n  }\n  const nextPos = endMarkerPos + endMarker.length;\n  if (endMarker.length === 1) {\n    // Skip if $ is preceded by a space character\n    const beforeEndMarker = state.src.charCodeAt(endMarkerPos - 1);\n    if (beforeEndMarker === 0x20 /* space */\n      || beforeEndMarker === 0x09 /* \\t */\n      || beforeEndMarker === 0x0a /* \\n */) {\n      return false;\n    }\n    // Skip if closing $ is succeeded by a digit (eg $5 $10 ...)\n    const suffix = state.src.charCodeAt(nextPos);\n    if (suffix >= 0x30 && suffix < 0x3A) {\n      return false;\n    }\n  }\n\n  if (!silent) {\n    const token = state.push(endMarker.length === 1 ? 'inline_math' : 'display_math', '', 0);\n    token.content = state.src.slice(startMathPos, endMarkerPos);\n  }\n  state.pos = nextPos;\n  return true;\n}\n\nexport default (md) => {\n  md.inline.ruler.push('texMath', texMath);\n};\n"
  },
  {
    "path": "src/extensions/libs/markdownItTasklist.js",
    "content": "function attrSet(token, name, value) {\n  const index = token.attrIndex(name);\n  const attr = [name, value];\n\n  if (index < 0) {\n    token.attrPush(attr);\n  } else {\n    token.attrs[index] = attr;\n  }\n}\n\nmodule.exports = (md) => {\n  md.core.ruler.after('inline', 'tasklist', ({ tokens, Token }) => {\n    for (let i = 2; i < tokens.length; i += 1) {\n      const token = tokens[i];\n      if (token.content\n        && token.content.charCodeAt(0) === 0x5b /* [ */\n        && token.content.charCodeAt(2) === 0x5d /* ] */\n        && token.content.charCodeAt(3) === 0x20 /* space */\n        && token.type === 'inline'\n        && tokens[i - 1].type === 'paragraph_open'\n        && tokens[i - 2].type === 'list_item_open'\n      ) {\n        const cross = token.content[1].toLowerCase();\n        if (cross === ' ' || cross === 'x') {\n          const checkbox = new Token('html_inline', '', 0);\n          if (cross === ' ') {\n            checkbox.content = '<span class=\"task-list-item-checkbox\" type=\"checkbox\">&#9744;</span>';\n          } else {\n            checkbox.content = '<span class=\"task-list-item-checkbox checked\" type=\"checkbox\">&#9745;</span>';\n          }\n          token.children.unshift(checkbox);\n          token.children[1].content = token.children[1].content.slice(3);\n          token.content = token.content.slice(3);\n          attrSet(tokens[i - 2], 'class', 'task-list-item');\n        }\n      }\n    }\n  });\n};\n"
  },
  {
    "path": "src/extensions/markdownExtension.js",
    "content": "import Prism from 'prismjs';\nimport markdownitAbbr from 'markdown-it-abbr';\nimport markdownitDeflist from 'markdown-it-deflist';\nimport markdownitFootnote from 'markdown-it-footnote';\nimport markdownitMark from 'markdown-it-mark';\nimport markdownitImgsize from 'markdown-it-imsize';\nimport markdownitSub from 'markdown-it-sub';\nimport markdownitSup from 'markdown-it-sup';\nimport markdownitTasklist from './libs/markdownItTasklist';\nimport markdownitAnchor from './libs/markdownItAnchor';\nimport extensionSvc from '../services/extensionSvc';\n\nconst coreBaseRules = [\n  'normalize',\n  'block',\n  'inline',\n  'linkify',\n  'replacements',\n  'smartquotes',\n];\nconst blockBaseRules = [\n  'code',\n  'fence',\n  'blockquote',\n  'hr',\n  'list',\n  'reference',\n  'heading',\n  'lheading',\n  'html_block',\n  'table',\n  'paragraph',\n];\nconst inlineBaseRules = [\n  'text',\n  'newline',\n  'escape',\n  'backticks',\n  'strikethrough',\n  'emphasis',\n  'link',\n  'image',\n  'autolink',\n  'html_inline',\n  'entity',\n];\nconst inlineBaseRules2 = [\n  'balance_pairs',\n  'strikethrough',\n  'emphasis',\n  'text_collapse',\n];\n\nextensionSvc.onGetOptions((options, properties) => Object\n  .assign(options, properties.extensions.markdown));\n\nextensionSvc.onInitConverter(0, (markdown, options) => {\n  markdown.set({\n    html: true,\n    breaks: !!options.breaks,\n    linkify: !!options.linkify,\n    typographer: !!options.typographer,\n    langPrefix: 'prism language-',\n  });\n\n  markdown.core.ruler.enable(coreBaseRules);\n\n  const blockRules = blockBaseRules.slice();\n  if (!options.fence) {\n    blockRules.splice(blockRules.indexOf('fence'), 1);\n  }\n  if (!options.table) {\n    blockRules.splice(blockRules.indexOf('table'), 1);\n  }\n  markdown.block.ruler.enable(blockRules);\n\n  const inlineRules = inlineBaseRules.slice();\n  const inlineRules2 = inlineBaseRules2.slice();\n  if (!options.del) {\n    inlineRules.splice(blockRules.indexOf('strikethrough'), 1);\n    inlineRules2.splice(blockRules.indexOf('strikethrough'), 1);\n  }\n  markdown.inline.ruler.enable(inlineRules);\n  markdown.inline.ruler2.enable(inlineRules2);\n\n  if (options.abbr) {\n    markdown.use(markdownitAbbr);\n  }\n  if (options.deflist) {\n    markdown.use(markdownitDeflist);\n  }\n  if (options.footnote) {\n    markdown.use(markdownitFootnote);\n  }\n  if (options.imgsize) {\n    markdown.use(markdownitImgsize);\n  }\n  if (options.mark) {\n    markdown.use(markdownitMark);\n  }\n  if (options.sub) {\n    markdown.use(markdownitSub);\n  }\n  if (options.sup) {\n    markdown.use(markdownitSup);\n  }\n  if (options.tasklist) {\n    markdown.use(markdownitTasklist);\n  }\n  markdown.use(markdownitAnchor);\n\n  // Wrap tables into a div for scrolling\n  markdown.renderer.rules.table_open = (tokens, idx, opts) =>\n    `<div class=\"table-wrapper\">${markdown.renderer.renderToken(tokens, idx, opts)}`;\n  markdown.renderer.rules.table_close = (tokens, idx, opts) =>\n    `${markdown.renderer.renderToken(tokens, idx, opts)}</div>`;\n\n  // Transform style into align attribute to pass the HTML sanitizer\n  const textAlignLength = 'text-align:'.length;\n  markdown.renderer.rules.td_open = (tokens, idx, opts) => {\n    const token = tokens[idx];\n    if (token.attrs && token.attrs.length && token.attrs[0][0] === 'style') {\n      token.attrs = [\n        ['align', token.attrs[0][1].slice(textAlignLength)],\n      ];\n    }\n    return markdown.renderer.renderToken(tokens, idx, opts);\n  };\n  markdown.renderer.rules.th_open = markdown.renderer.rules.td_open;\n\n  markdown.renderer.rules.footnote_ref = (tokens, idx) => {\n    const n = `${Number(tokens[idx].meta.id + 1)}`;\n    let id = `fnref${n}`;\n    if (tokens[idx].meta.subId > 0) {\n      id += `:${tokens[idx].meta.subId}`;\n    }\n    return `<sup class=\"footnote-ref\"><a href=\"#fn${n}\" id=\"${id}\">${n}</a></sup>`;\n  };\n});\n\nextensionSvc.onSectionPreview((elt, options, isEditor) => {\n  // Highlight with Prism\n  elt.querySelectorAll('.prism').cl_each((prismElt) => {\n    if (!prismElt.$highlightedWithPrism) {\n      Prism.highlightElement(prismElt);\n      prismElt.$highlightedWithPrism = true;\n    }\n  });\n\n  // Transform task spans into checkboxes\n  elt.querySelectorAll('span.task-list-item-checkbox').cl_each((spanElt) => {\n    const checkboxElt = document.createElement('input');\n    checkboxElt.type = 'checkbox';\n    checkboxElt.className = 'task-list-item-checkbox';\n    if (spanElt.classList.contains('checked')) {\n      checkboxElt.setAttribute('checked', true);\n    }\n    if (!isEditor) {\n      checkboxElt.disabled = 'disabled';\n    }\n    spanElt.parentNode.replaceChild(checkboxElt, spanElt);\n  });\n});\n"
  },
  {
    "path": "src/extensions/mermaidExtension.js",
    "content": "import 'mermaid';\nimport extensionSvc from '../services/extensionSvc';\nimport utils from '../services/utils';\n\nconst config = {\n  logLevel: 5,\n  startOnLoad: false,\n  arrowMarkerAbsolute: false,\n  theme: 'neutral',\n  flowchart: {\n    htmlLabels: true,\n    curve: 'linear',\n  },\n  sequence: {\n    diagramMarginX: 50,\n    diagramMarginY: 10,\n    actorMargin: 50,\n    width: 150,\n    height: 65,\n    boxMargin: 10,\n    boxTextMargin: 5,\n    noteMargin: 10,\n    messageMargin: 35,\n    mirrorActors: true,\n    bottomMarginAdj: 1,\n    useMaxWidth: true,\n  },\n  gantt: {\n    titleTopMargin: 25,\n    barHeight: 20,\n    barGap: 4,\n    topPadding: 50,\n    leftPadding: 75,\n    gridLineStartPadding: 35,\n    fontSize: 11,\n    fontFamily: '\"Open-Sans\", \"sans-serif\"',\n    numberSectionStyles: 4,\n    axisFormat: '%Y-%m-%d',\n  },\n};\n\nconst containerElt = document.createElement('div');\ncontainerElt.className = 'hidden-rendering-container';\ndocument.body.appendChild(containerElt);\n\nlet init = () => {\n  window.mermaid.initialize(config);\n  init = () => {};\n};\n\nconst render = (elt) => {\n  try {\n    init();\n    const svgId = `mermaid-svg-${utils.uid()}`;\n    window.mermaid.mermaidAPI.render(svgId, elt.textContent, () => {\n      while (elt.firstChild) {\n        elt.removeChild(elt.lastChild);\n      }\n      elt.appendChild(containerElt.querySelector(`#${svgId}`));\n    }, containerElt);\n  } catch (e) {\n    console.error(e); // eslint-disable-line no-console\n  }\n};\n\nextensionSvc.onGetOptions((options, properties) => {\n  options.mermaid = properties.extensions.mermaid.enabled;\n});\n\nextensionSvc.onSectionPreview((elt) => {\n  elt.querySelectorAll('.prism.language-mermaid')\n    .cl_each(diagramElt => render(diagramElt.parentNode));\n});\n"
  },
  {
    "path": "src/icons/Alert.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 13,14L 11,14L 11,9.99998L 13,9.99998M 13,18L 11,18L 11,16L 13,16M 1,21L 23,21L 12,1.99998L 1,21 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/ArrowLeft.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 20,11L 20,13L 7.98958,13L 13.4948,18.5052L 12.0806,19.9194L 4.16116,12L 12.0806,4.08058L 13.4948,5.49479L 7.98958,11L 20,11 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/CheckCircle.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 12,2C 17.5228,2 22,6.47716 22,12C 22,17.5228 17.5228,22 12,22C 6.47715,22 2,17.5228 2,12C 2,6.47716 6.47715,2 12,2 Z M 10.9999,16.5019L 17.9999,9.50193L 16.5859,8.08794L 10.9999,13.6739L 7.91391,10.5879L 6.49991,12.0019L 10.9999,16.5019 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Close.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/CodeBraces.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 8,3C 6.89543,3 6,3.89539 6,5L 6,9C 6,10.1046 5.10457,11 4,11L 3,11L 3,13L 4,13C 5.10457,13 6,13.8954 6,15L 6,19C 6,20.1046 6.92841,20.7321 8,21L 10,21L 10,19L 8,19L 8,14C 8,12.8954 7.10457,12 6,12C 7.10457,12 8,11.1046 8,10L 8,5L 10,5L 10,3M 16,3C 17.1046,3 18,3.89539 18,5L 18,9C 18,10.1046 18.8954,11 20,11L 21,11L 21,13L 20,13C 18.8954,13 18,13.8954 18,15L 18,19C 18,20.1046 17.0716,20.7321 16,21L 14,21L 14,19L 16,19L 16,14C 16,12.8954 16.8954,12 18,12C 16.8954,12 16,11.1046 16,10L 16,5L 14,5L 14,3L 16,3 Z \" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/CodeTags.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"2 2 20 20\">\n    <path d=\"M 14.6,16.6L 19.2,12L 14.6,7.4L 16,6L 22,12L 16,18L 14.6,16.6 Z M 9.4,16.6L 4.8,12L 9.4,7.4L 8,6L 2,12L 8,18L 9.4,16.6 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/ContentCopy.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 19,21L 8,21L 8,7L 19,7M 19,5L 8,5C 6.9,5 6,5.9 6,7L 6,21C 6,22.1 6.9,23 8,23L 19,23C 20.1,23 21,22.1 21,21L 21,7C 21,5.9 20.1,5 19,5 Z M 16,1L 4,1C 2.9,1 2,1.9 2,3L 2,17L 4,17L 4,3L 16,3L 16,1 Z \" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/ContentSave.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M15,9H5V5H15M12,19C10.34,19 9,17.66 9,16C9,14.34 10.34,13 12,13C13.66,13 15,14.34 15,16C15,17.66 13.66,19 12,19M17,3H5C3.89,3 3,3.9 3,5V19C3,20.1 3.9,21 5,21H19C20.1,21 21,20.1 21,19V7L17,3Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Database.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Delete.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/DotsHorizontal.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 16,12C 16,10.8954 16.8954,10 18,10C 19.1046,10 20,10.8954 20,12C 20,13.1046 19.1046,14 18,14C 16.8954,14 16,13.1046 16,12 Z M 10,12C 10,10.8954 10.8954,10 12,10C 13.1046,10 14,10.8954 14,12C 14,13.1046 13.1046,14 12,14C 10.8954,14 10,13.1046 10,12 Z M 4,12C 4,10.8954 4.89543,10 6,10C 7.10457,10 8,10.8954 8,12C 8,13.1046 7.10457,14 6,14C 4.89543,14 4,13.1046 4,12 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Download.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 4.9994,19.9981L 18.9994,19.9981L 18.9994,17.9981L 4.9994,17.9981M 18.9994,8.99807L 14.9994,8.99807L 14.9994,2.99807L 8.9994,2.99807L 8.9994,8.99807L 4.9994,8.99807L 11.9994,15.9981L 18.9994,8.99807 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Eye.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 11.9994,8.99813C 10.3424,8.99813 8.99941,10.3411 8.99941,11.9981C 8.99941,13.6551 10.3424,14.9981 11.9994,14.9981C 13.6564,14.9981 14.9994,13.6551 14.9994,11.9981C 14.9994,10.3411 13.6564,8.99813 11.9994,8.99813 Z M 11.9994,16.9981C 9.23841,16.9981 6.99941,14.7591 6.99941,11.9981C 6.99941,9.23714 9.23841,6.99813 11.9994,6.99813C 14.7604,6.99813 16.9994,9.23714 16.9994,11.9981C 16.9994,14.7591 14.7604,16.9981 11.9994,16.9981 Z M 11.9994,4.49813C 6.99741,4.49813 2.72741,7.60915 0.99941,11.9981C 2.72741,16.3871 6.99741,19.4981 11.9994,19.4981C 17.0024,19.4981 21.2714,16.3871 22.9994,11.9981C 21.2714,7.60915 17.0024,4.49813 11.9994,4.49813 Z \" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FileImage.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 12.9994,8.99807L 18.4994,8.99807L 12.9994,3.49807L 12.9994,8.99807 Z M 5.99938,1.99809L 13.9994,1.99809L 19.9994,7.99808L 19.9994,19.9981C 19.9994,21.1021 19.1034,21.9981 17.9994,21.9981L 5.98937,21.9981C 4.88537,21.9981 3.99939,21.1021 3.99939,19.9981L 4.0094,3.99808C 4.0094,2.89407 4.89437,1.99809 5.99938,1.99809 Z M 6,20L 15,20L 18,20L 18,12L 14,16L 12,14L 6,20 Z M 8,9C 6.89543,9 6,9.89543 6,11C 6,12.1046 6.89543,13 8,13C 9.10457,13 10,12.1046 10,11C 10,9.89543 9.10457,9 8,9 Z \" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FileMultiple.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"-2 -2 26 26\">\n    <path d=\"M15,7H20.5L15,1.5V7M8,0H16L22,6V18C22,19.1 21.1,20 20,20H8C6.89,20 6,19.1 6,18V2C6,0.9 6.9,0 8,0M4,4V22H20V24H4C2.9,24 2,23.1 2,22V4H4Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FilePlus.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M13,9H18.5L13,3.5V9M6,2H14L20,8V20C20,21.1 19.1,22 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M11,15V12H9V15H6V17H9V20H11V17H14V15H11Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Folder.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M10,4H4C2.89,4 2,4.89 2,6V18C2,19.1 2.9,20 4,20H20C21.1,20 22,19.1 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FolderMultiple.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M22,4H14L12,2H6C4.9,2 4,2.9 4,4V16C4,17.1 4.9,18 6,18H22C23.1,18 24,17.1 24,16V6C24,4.9 23.1,4 22,4M2,6H0V11H0V20C0,21.1 0.9,22 2,22H20V20H2V6Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FolderPlus.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M10,4L12,6H20C21.1,6 22,6.9 22,8V18C22,19.1 21.1,20 20,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10M15,9V12H12V14H15V17H17V14H20V12H17V9H15Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatBold.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M13.35,17.401l-4.201,0l0,-3.601l4.201,0c0.997,0 1.801,0.805 1.801,1.801c0,0.996 -0.804,1.8 -1.801,1.8m-4.201,-10.802l3.601,0c0.996,0 1.801,0.804 1.801,1.8c0,0.996 -0.805,1.801 -1.801,1.801l-3.601,0m6.722,1.548c1.164,-0.816 1.98,-2.149 1.98,-3.349c0,-2.712 -2.1,-4.801 -4.801,-4.801l-7.502,0l0,16.804l8.45,0c2.521,0 4.454,-2.04 4.454,-4.549c0,-1.825 -1.033,-3.385 -2.581,-4.105Z\" style=\"fill-rule:nonzero;\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatItalic.vue",
    "content": "<template>\n\t<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n\t\t<path d=\"M8.617,3.658l0,3.575l2.633,0l-2.075,9.534l-3.325,0l0,3.575l9.533,0l0,-3.575l-2.633,0l2.075,-9.534l3.325,0l0,-3.575l-9.533,0Z\" style=\"fill-rule:nonzero;\"/>\n\t</svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatListBulleted.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M7.043,4.695l14.61,0l0,2.087l-14.61,0l0,-2.087m0,8.349l0,-2.088l14.61,0l0,2.088l-14.61,0m-3.131,-8.871c0.866,0 1.566,0.699 1.566,1.565c0,0.867 -0.7,1.566 -1.566,1.566c-0.866,0 -1.565,-0.699 -1.565,-1.566c0,-0.866 0.699,-1.565 1.565,-1.565m0,6.262c0.866,0 1.566,0.699 1.566,1.565c0,0.866 -0.7,1.565 -1.566,1.565c-0.866,0 -1.565,-0.699 -1.565,-1.565c0,-0.866 0.699,-1.565 1.565,-1.565m3.131,8.87l0,-2.087l14.61,0l0,2.087l-14.61,0m-3.131,-2.609c0.866,0 1.566,0.699 1.566,1.566c0,0.866 -0.7,1.565 -1.566,1.565c-0.866,0 -1.565,-0.699 -1.565,-1.565c0,-0.867 0.699,-1.566 1.565,-1.566Z\" style=\"fill-rule:nonzero;\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatListChecks.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M3,5H9V11H3V5M5,7V9H7V7H5M11,7H21V9H11V7M11,15H21V17H11V15M5,20L1.5,16.5L2.91,15.09L5,17.17L9.59,12.59L11,14L5,20Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatListNumbers.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M7.235,13.059l14.825,0l0,-2.118l-14.825,0m0,8.471l14.825,0l0,-2.117l-14.825,0m0,-10.59l14.825,0l0,-2.117l-14.825,0m-5.295,6.353l1.906,0l-1.906,2.224l0,0.953l3.177,0l0,-1.059l-1.906,0l1.906,-2.224l0,-0.953l-3.177,0m1.059,-2.118l1.059,0l0,-4.235l-2.118,0l0,1.059l1.059,0m-1.059,12.707l2.118,0l0,0.529l-1.059,0l0,1.059l1.059,0l0,0.529l-2.118,0l0,1.059l3.177,0l0,-4.235l-3.177,0l0,1.059Z\" style=\"fill-rule:nonzero;\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatQuoteClose.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M14.446,18.235l2.92,0l1.946,-4.988l0,-7.482l-5.839,0l0,7.482l2.92,0m-10.732,4.988l2.919,0l1.947,-4.988l0,-7.482l-5.839,0l0,7.482l2.919,0l-1.946,4.988Z\" style=\"fill-rule:nonzero;\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatSize.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M2.007,12.526l3.156,0l0,7.363l3.155,0l0,-7.363l3.156,0l0,-3.156l-9.467,0m6.311,-5.259l0,3.155l5.26,0l0,12.623l3.156,0l0,-12.623l5.259,0l0,-3.155l-13.675,0Z\" style=\"fill-rule:nonzero;\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/FormatStrikethrough.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M20.874,12.059l0,1.729l-3.541,0c0.806,1.851 0.766,6.918 -5.026,6.918c-6.721,0.043 -6.463,-5.621 -6.463,-5.621l3.203,0.044c0.024,2.914 2.55,2.914 3.05,2.879c0.516,-0.043 2.444,-0.034 2.598,-2.058c0.064,-0.942 -0.823,-1.66 -1.791,-2.162l-9.778,0l0,-1.729l17.748,0m-2.896,-3.554l-3.211,-0.026c0,0 0.137,-2.395 -2.646,-2.404c-2.783,-0.017 -2.541,1.902 -2.541,2.144c0.032,0.243 0.274,1.436 2.42,2.007l-5.074,0c0,0 -2.816,-5.82 4.057,-6.814c7.027,-1.038 7.011,5.11 6.995,5.093Z\" style=\"fill-rule:nonzero;\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/HelpCircle.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 15.0661,11.2518L 14.1711,12.1697C 13.4471,12.8937 12.9991,13.4977 12.9991,14.9977L 10.9991,14.9977L 10.9991,14.4977C 10.9991,13.3937 11.4471,12.3937 12.1711,11.6697L 13.4141,10.4117C 13.7751,10.0497 13.9991,9.54974 13.9991,8.99774C 13.9991,7.89374 13.1041,6.99774 11.9991,6.99774C 10.8951,6.99774 9.99908,7.89374 9.99908,8.99774L 7.99908,8.99774C 7.99908,6.78876 9.7901,4.99774 11.9991,4.99774C 14.2091,4.99774 15.9991,6.78876 15.9991,8.99774C 15.9991,9.87775 15.6431,10.6747 15.0661,11.2518 Z M 12.9991,18.9977L 10.9991,18.9977L 10.9991,16.9977L 12.9991,16.9977M 11.9991,1.99774C 6.4761,1.99774 1.99908,6.47473 1.99908,11.9977C 1.99908,17.5217 6.4761,21.9977 11.9991,21.9977C 17.5231,21.9977 21.9991,17.5217 21.9991,11.9977C 21.9991,6.47473 17.5231,1.99774 11.9991,1.99774 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/History.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M11,7V12.11L15.71,14.9L16.5,13.62L12.5,11.25V7M12.5,2C8.97,2 5.91,3.92 4.27,6.77L2,4.5V11H8.5L5.75,8.25C6.96,5.73 9.5,4 12.5,4C16.64,4 20,7.36 20,11.5C20,15.64 16.64,19 12.5,19C9.23,19 6.47,16.91 5.44,14H3.34C4.44,18.03 8.11,21 12.5,21C17.74,21 22,16.75 22,11.5C22,6.25 17.75,2 12.5,2Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Information.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 12.9994,8.99805L 10.9994,8.99805L 10.9994,6.99805L 12.9994,6.99805M 12.9994,16.998L 10.9994,16.998L 10.9994,10.998L 12.9994,10.998M 11.9994,1.99805C 6.47642,1.99805 1.99943,6.47504 1.99943,11.998C 1.99943,17.5211 6.47642,21.998 11.9994,21.998C 17.5224,21.998 21.9994,17.5211 21.9994,11.998C 21.9994,6.47504 17.5224,1.99805 11.9994,1.99805 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Key.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 7,14C 5.9,14 5,13.1 5,12C 5,10.9 5.9,10 7,10C 8.1,10 9,10.9 9,12C 9,13.1 8.1,14 7,14 Z M 12.65,10C 11.83,7.67 9.61,6 7,6C 3.69,6 1,8.69 1,12C 1,15.31 3.69,18 7,18C 9.61,18 11.83,16.33 12.65,14L 17,14L 17,18L 21,18L 21,14L 23,14L 23,10L 12.65,10 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/LinkVariant.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 10.5858,13.4142C 10.9763,13.8047 10.9763,14.4379 10.5858,14.8284C 10.1952,15.2189 9.56207,15.2189 9.17154,14.8284C 7.21892,12.8758 7.21892,9.70995 9.17154,7.75733L 9.17157,7.75736L 12.707,4.2219C 14.6596,2.26928 17.8255,2.26929 19.7781,4.2219C 21.7307,6.17452 21.7307,9.34034 19.7781,11.293L 18.2925,12.7785C 18.3008,11.9583 18.1659,11.1368 17.8876,10.355L 18.3639,9.87865C 19.5355,8.70708 19.5355,6.80759 18.3639,5.63602C 17.1923,4.46445 15.2929,4.46445 14.1213,5.63602L 10.5858,9.17155C 9.41419,10.3431 9.41419,12.2426 10.5858,13.4142 Z M 13.4142,9.17155C 13.8047,8.78103 14.4379,8.78103 14.8284,9.17155C 16.781,11.1242 16.781,14.29 14.8284,16.2426L 14.8284,16.2426L 11.2929,19.7782C 9.34026,21.7308 6.17444,21.7308 4.22182,19.7782C 2.26921,17.8255 2.2692,14.6597 4.22182,12.7071L 5.70744,11.2215C 5.69913,12.0417 5.8341,12.8631 6.11234,13.645L 5.63601,14.1213C 4.46444,15.2929 4.46444,17.1924 5.63601,18.3639C 6.80758,19.5355 8.70708,19.5355 9.87865,18.3639L 13.4142,14.8284C 14.5858,13.6568 14.5858,11.7573 13.4142,10.5858C 13.0237,10.1952 13.0237,9.56207 13.4142,9.17155 Z \" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Login.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path fill=\"#000000\" fill-opacity=\"1\" stroke-width=\"0.2\" stroke-linejoin=\"round\" d=\"M 10,17.25L 10,14L 3.00002,14L 3.00002,10L 10,10L 10,6.75L 15.25,12L 10,17.25 Z M 7.99999,2.00003L 17,2.00005C 18.1045,2.00005 19,2.89546 19,4.00003L 19,20C 19,21.1046 18.1045,22 17,22L 7.99999,22C 6.89542,22 5.99999,21.1046 5.99999,20L 6,16L 7.99999,16L 7.99999,20L 17,20L 17,4.00003L 7.99999,4.00002L 7.99999,8.00001L 6,8.00001L 5.99999,4.00002C 5.99999,2.89545 6.89542,2.00003 7.99999,2.00003 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Logout.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path fill=\"#000000\" fill-opacity=\"1\" stroke-width=\"0.2\" stroke-linejoin=\"round\" d=\"M 16.9999,17.25L 16.9999,14L 9.99998,14L 9.99998,10L 16.9999,10L 16.9999,6.75L 22.2499,12L 16.9999,17.25 Z M 13,2.00002C 14.1046,2.00002 15,2.89545 15,4.00002L 15,8L 13,8L 13,4.00002L 4,4.00004L 4,20L 13,20L 13,16L 15,16L 15,20C 15,21.1046 14.1046,22 13,22L 4,22C 2.89543,22 2,21.1046 2,20L 2,4.00004C 2,2.89547 2.89543,2.00006 4,2.00006L 13,2.00002 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Magnify.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 9.5,3C 13.0899,3 16,5.91015 16,9.5C 16,11.1149 15.411,12.5923 14.4362,13.7291L 14.7071,14L 15.5,14L 20.5,19L 19,20.5L 14,15.5L 14,14.7071L 13.7291,14.4362C 12.5923,15.411 11.1149,16 9.5,16C 5.91015,16 3,13.0899 3,9.5C 3,5.91015 5.91015,3 9.5,3 Z M 9.5,5.00001C 7.01472,5.00001 5,7.01473 5,9.50001C 5,11.9853 7.01472,14 9.5,14C 11.9853,14 14,11.9853 14,9.50001C 14,7.01473 11.9853,5.00001 9.5,5.00001 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Menu.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path fill=\"#000000\" fill-opacity=\"1\" stroke-width=\"0.2\" stroke-linejoin=\"round\" d=\"M 3,6L 21,6L 21,8L 3,8L 3,6 Z M 3,11L 21,11L 21,13L 3,13L 3,11 Z M 3,16L 21,16L 21,18L 3,18L 3,16 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Message.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 21.9891,3.99805C 21.9891,2.89404 21.1031,1.99805 19.9991,1.99805L 3.99913,1.99805C 2.89512,1.99805 1.99913,2.89404 1.99913,3.99805L 1.99913,15.998C 1.99913,17.1021 2.89512,17.998 3.99913,17.998L 17.9991,17.998L 21.9991,21.998L 21.9891,3.99805 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/NavigationBar.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M19,8.977l-14,0l0,10l14,0m0,2l-14,0c-1.104,0 -2,-0.896 -2,-2l0,-10c0,-1.105 0.896,-2 2,-2l14,0c1.105,0 2,0.895 2,2l0,10c0,1.104 -0.895,2 -2,2Z\" />\n    <rect x=\"3\" y=\"3.023\" width=\"18\" height=\"2\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/OpenInNew.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 14,3L 14,5L 17.59,5L 7.76,14.83L 9.17,16.24L 19,6.41L 19,10L 21,10L 21,3M 19,19L 5,19L 5,5L 12,5L 12,3L 5,3C 3.89,3 3,3.9 3,5L 3,19C 3,20.1 3.89,21 5,21L 19,21C 20.1,21 21,20.1 21,19L 21,12L 19,12L 19,19 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Pen.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 16.8363,2.73375C 16.45,2.73375 16.0688,2.88125 15.7712,3.17375L 13.6525,5.2925L 18.955,10.5962L 21.0737,8.47625C 21.665,7.89 21.665,6.94375 21.0737,6.3575L 17.895,3.17375C 17.6025,2.88125 17.2163,2.73375 16.8363,2.73375 Z M 12.9437,6.00125L 4.84375,14.1062L 7.4025,14.39L 7.57875,16.675L 9.85875,16.85L 10.1462,19.4088L 18.2475,11.3038M 4.2475,15.0437L 2.515,21.7337L 9.19875,19.9412L 8.955,17.7838L 6.645,17.6075L 6.465,15.2925\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Printer.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M18,3H6V7H18M19,12C18.45,12 18,11.55 18,11C18,10.45 18.45,10 19,10C19.55,10 20,10.45 20,11C20,11.55 19.55,12 19,12M16,19H8V14H16M19,8H5C3.34,8 2,9.34 2,11V17H6V21H18V17H22V11C22,9.34 20.66,8 19,8Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Provider.vue",
    "content": "<template>\n  <div class=\"icon-provider\" :class=\"'icon-provider--' + classState\">\n    <icon-sync-off v-if=\"!classState\"></icon-sync-off>\n  </div>\n</template>\n\n<script>\nexport default {\n  props: ['providerId'],\n  computed: {\n    classState() {\n      switch (this.providerId) {\n        case 'googleDrive':\n        case 'googleDriveAppData':\n        case 'googleDriveWorkspace':\n          return 'google-drive';\n        case 'googlePhotos':\n          return 'google-photos';\n        case 'githubWorkspace':\n          return 'github';\n        case 'gist':\n          return 'github';\n        case 'gitlabWorkspace':\n          return 'gitlab';\n        case 'bloggerPage':\n          return 'blogger';\n        case 'couchdbWorkspace':\n          return 'couchdb';\n        default:\n          return this.providerId;\n      }\n    },\n  },\n};\n</script>\n\n<style lang=\"scss\">\n.icon-provider {\n  width: 100%;\n  height: 100%;\n  background-position: center;\n  background-repeat: no-repeat;\n  background-size: contain;\n}\n\n.icon-provider--stackedit {\n  background-image: url(../assets/iconStackedit.svg);\n}\n\n.icon-provider--google-drive {\n  background-image: url(../assets/iconGoogleDrive.svg);\n}\n\n.icon-provider--google-photos {\n  background-image: url(../assets/iconGooglePhotos.svg);\n}\n\n.icon-provider--github {\n  background-image: url(../assets/iconGithub.svg);\n}\n\n.icon-provider--gitlab {\n  background-image: url(../assets/iconGitlab.svg);\n}\n\n.icon-provider--google {\n  background-image: url(../assets/iconGoogle.svg);\n}\n\n.icon-provider--dropbox {\n  background-image: url(../assets/iconDropbox.svg);\n}\n\n.icon-provider--wordpress {\n  background-image: url(../assets/iconWordpress.svg);\n}\n\n.icon-provider--blogger {\n  background-image: url(../assets/iconBlogger.svg);\n}\n\n.icon-provider--zendesk {\n  background-image: url(../assets/iconZendesk.svg);\n}\n\n.icon-provider--couchdb {\n  background-image: url(../assets/iconCouchdb.svg);\n}\n</style>\n"
  },
  {
    "path": "src/icons/Redo.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M18.4,10.6C16.55,9 14.15,8 11.5,8C6.85,8 2.92,11.03 1.54,15.22L3.9,16C4.95,12.81 7.95,10.5 11.5,10.5C13.45,10.5 15.23,11.22 16.62,12.38L13,16H22V7L18.4,10.6Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/ScrollSync.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M9,18l3,0l-4,4l-4,-4l3,0l0,-3l2,0l0,3Zm8,0l3,0l-4,4l-4,-4l3,0l0,-3l2,0l0,3Zm0.055,-5l-10.11,0l0,-2l10.11,0l0,2Zm-8.055,-4l-2,0l0,-3l-3,0l4,-4l4,4l4,-4l4,4l-3,0l0,3l-2,0l0,-3l-6,0l0,3Z\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Seal.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"1 1 23 23\">\n    <path d=\"M 20.3943,19.3706L 16.3828,17.9893L 15.0016,22.0008L 11.9248,15.9996L 8.99895,21.9986L 7.61768,17.9871L 3.60619,19.3683L 6.53159,13.3704C 5.57315,12.1727 5,10.6533 5,9C 5,5.13401 8.13401,2 12,2C 15.866,2 19,5.13401 19,9C 19,10.6535 18.4267,12.1731 17.468,13.3708L 20.3943,19.3706 Z M 7,9.00001L 9.68578,10.3429L 9.50615,13.3356L 12.012,11.6811L 14.514,13.333L 14.334,10.3356L 17.0156,8.9948L 14.323,7.64851L 14.5017,4.6727L 12.0162,6.31371L 9.49384,4.64828L 9.67477,7.66262L 7,9.00001 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Settings.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 11.9994,15.498C 10.0664,15.498 8.49939,13.931 8.49939,11.998C 8.49939,10.0651 10.0664,8.49805 11.9994,8.49805C 13.9324,8.49805 15.4994,10.0651 15.4994,11.998C 15.4994,13.931 13.9324,15.498 11.9994,15.498 Z M 19.4284,12.9741C 19.4704,12.6531 19.4984,12.329 19.4984,11.998C 19.4984,11.6671 19.4704,11.343 19.4284,11.022L 21.5414,9.36804C 21.7294,9.21606 21.7844,8.94604 21.6594,8.73004L 19.6594,5.26605C 19.5354,5.05005 19.2734,4.96204 19.0474,5.04907L 16.5584,6.05206C 16.0424,5.65607 15.4774,5.32104 14.8684,5.06903L 14.4934,2.41907C 14.4554,2.18103 14.2484,1.99805 13.9994,1.99805L 9.99939,1.99805C 9.74939,1.99805 9.5434,2.18103 9.5054,2.41907L 9.1304,5.06805C 8.52039,5.32104 7.95538,5.65607 7.43939,6.05206L 4.95139,5.04907C 4.7254,4.96204 4.46338,5.05005 4.33939,5.26605L 2.33939,8.73004C 2.21439,8.94604 2.26938,9.21606 2.4574,9.36804L 4.5694,11.022C 4.5274,11.342 4.49939,11.6671 4.49939,11.998C 4.49939,12.329 4.5274,12.6541 4.5694,12.9741L 2.4574,14.6271C 2.26938,14.78 2.21439,15.05 2.33939,15.2661L 4.33939,18.73C 4.46338,18.946 4.7254,19.0341 4.95139,18.947L 7.4404,17.944C 7.95639,18.34 8.52139,18.675 9.1304,18.9271L 9.5054,21.577C 9.5434,21.8151 9.74939,21.998 9.99939,21.998L 13.9994,21.998C 14.2484,21.998 14.4554,21.8151 14.4934,21.577L 14.8684,18.9271C 15.4764,18.6741 16.0414,18.34 16.5574,17.9431L 19.0474,18.947C 19.2734,19.0341 19.5354,18.946 19.6594,18.73L 21.6594,15.2661C 21.7844,15.05 21.7294,14.78 21.5414,14.6271L 19.4284,12.9741 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/SidePreview.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M11,20.977l-6,0c-1.104,0 -2,-0.896 -2,-2l0,-14c0,-1.105 0.896,-2 2,-2l14,0c1.105,0 2,0.895 2,2l0,14c0,1.104 -0.895,2 -2,2l-6,0l0,0.023l-2,0l0,-0.023Zm0,-2l0,-14l-6,0l0,14l6,0Zm8,-14l-6,0l0,14l6,0l0,-14Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/SignalOff.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 18,3L 18,16.1777L 21,19.1777L 21,3L 18,3 Z M 4.27734,5L 3,6.2676L 10.7324,14L 8,14L 8,21L 11,21L 11,14.2676L 13,16.2676L 13,21L 16,21L 16,19.2676L 19.7324,23L 21,21.7227L 4.27734,5 Z M 13,9L 13,11.1777L 16,14.1777L 16,9L 13,9 Z M 3,18L 3,21L 6,21L 6,18L 3,18 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/StatusBar.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M19,15.023l-14,0l0,-10l14,0m0,-2l-14,0c-1.104,0 -2,0.896 -2,2l0,10c0,1.105 0.896,2 2,2l14,0c1.105,0 2,-0.895 2,-2l0,-10c0,-1.104 -0.895,-2 -2,-2Z\" />\n    <rect x=\"3\" y=\"18.977\" width=\"18\" height=\"2\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Sync.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M12,18C8.69,18 6,15.31 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12C4,16.42 7.58,20 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6C15.31,6 18,8.69 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12C20,7.58 16.42,4 12,4Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/SyncOff.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M20,4H14V10L16.24,7.76C17.32,8.85 18,10.34 18,12C18,13 17.75,13.94 17.32,14.77L18.78,16.23C19.55,15 20,13.56 20,12C20,9.79 19.09,7.8 17.64,6.36L20,4M2.86,5.41L5.22,7.77C4.45,9 4,10.44 4,12C4,14.21 4.91,16.2 6.36,17.64L4,20H10V14L7.76,16.24C6.68,15.15 6,13.66 6,12C6,11 6.25,10.06 6.68,9.23L14.76,17.31C14.5,17.44 14.26,17.56 14,17.65V19.74C14.79,19.53 15.54,19.2 16.22,18.78L18.58,21.14L19.85,19.87L4.14,4.14L2.86,5.41M10,6.35V4.26C9.2,4.47 8.45,4.8 7.77,5.22L9.23,6.68C9.5,6.56 9.73,6.44 10,6.35Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Table.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 5,4L 19,4C 20.1046,4 21,4.89543 21,6L 21,18C 21,19.1046 20.1046,20 19,20L 5,20C 3.89543,20 3,19.1046 3,18L 3,6C 3,4.89543 3.89543,4 5,4 Z M 5,8L 5,12L 11,12L 11,8L 5,8 Z M 13,8L 13,12L 19,12L 19,8L 13,8 Z M 5,14L 5,18L 11,18L 11,14L 5,14 Z M 13,14L 13,18L 19,18L 19,14L 13,14 Z \" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Target.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 11.0013,2.0025L 11.0013,4.0725C 7.3825,4.53125 4.53125,7.3825 4.0725,11.0012L 2.0025,11.0012L 2.0025,12.9975L 4.0725,12.9975C 4.53125,16.6213 7.3825,19.4675 11.0013,19.9262L 11.0013,22.0025L 12.9975,22.0025L 12.9975,19.9313C 16.6212,19.4675 19.4675,16.6213 19.9263,12.9975L 22.0025,12.9975L 22.0025,11.0012L 19.9312,11.0012C 19.4675,7.3825 16.6212,4.53125 12.9975,4.0725L 12.9975,2.0025M 11.0013,6.08375L 11.0013,7.9975L 12.9975,7.9975L 12.9975,6.08875C 15.5175,6.51375 17.485,8.48625 17.915,11.0012L 16.0012,11.0012L 16.0012,12.9975L 17.91,12.9975C 17.485,15.5175 15.5125,17.485 12.9975,17.915L 12.9975,16.0012L 11.0013,16.0012L 11.0013,17.91C 8.48625,17.485 6.51375,15.5125 6.08375,12.9975L 7.9975,12.9975L 7.9975,11.0012L 6.08875,11.0012C 6.51375,8.48625 8.48625,6.51375 11.0013,6.08375 Z M 12.0025,11.0012C 11.445,11.0012 11.0013,11.445 11.0013,12.0025C 11.0013,12.5538 11.445,12.9975 12.0025,12.9975C 12.5537,12.9975 12.9975,12.5538 12.9975,12.0025C 12.9975,11.445 12.5537,11.0012 12.0025,11.0012 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Toc.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M3 9h14V7H3v2zm0 4h14v-2H3v2zm0 4h14v-2H3v2zm16 0h2v-2h-2v2zm0-10v2h2V7h-2zm0 6h2v-2h-2v2z\"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Undo.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z\" />\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/Upload.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 8.99939,15.998L 8.99939,9.99805L 4.99939,9.99805L 11.9994,2.99805L 18.9994,9.99805L 14.9994,9.99805L 14.9994,15.998L 8.99939,15.998 Z M 4.99937,19.9981L 4.99937,17.9981L 18.9994,17.9981L 18.9994,19.9981L 4.99937,19.9981 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/ViewList.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon\" viewBox=\"0 0 24 24\">\n    <path d=\"M 9,5L 9,9L 21,9L 21,5M 9,19L 21,19L 21,15L 9,15M 9,14L 21,14L 21,10L 9,10M 4,9L 8,9L 8,5L 4,5M 4,19L 8,19L 8,15L 4,15M 4,14L 8,14L 8,10L 4,10L 4,14 Z \"/>\n  </svg>\n</template>\n"
  },
  {
    "path": "src/icons/index.js",
    "content": "import Vue from 'vue';\nimport Provider from './Provider';\nimport FormatBold from './FormatBold';\nimport FormatItalic from './FormatItalic';\nimport FormatQuoteClose from './FormatQuoteClose';\nimport LinkVariant from './LinkVariant';\nimport FileImage from './FileImage';\nimport Table from './Table';\nimport FormatListNumbers from './FormatListNumbers';\nimport FormatListBulleted from './FormatListBulleted';\nimport FormatSize from './FormatSize';\nimport FormatStrikethrough from './FormatStrikethrough';\nimport StatusBar from './StatusBar';\nimport NavigationBar from './NavigationBar';\nimport SidePreview from './SidePreview';\nimport Eye from './Eye';\nimport Settings from './Settings';\nimport FilePlus from './FilePlus';\nimport FileMultiple from './FileMultiple';\nimport FolderPlus from './FolderPlus';\nimport Delete from './Delete';\nimport Close from './Close';\nimport Pen from './Pen';\nimport Target from './Target';\nimport ArrowLeft from './ArrowLeft';\nimport HelpCircle from './HelpCircle';\nimport Toc from './Toc';\nimport Login from './Login';\nimport Logout from './Logout';\nimport Sync from './Sync';\nimport SyncOff from './SyncOff';\nimport Upload from './Upload';\nimport ViewList from './ViewList';\nimport Download from './Download';\nimport CodeTags from './CodeTags';\nimport CodeBraces from './CodeBraces';\nimport OpenInNew from './OpenInNew';\nimport Information from './Information';\nimport Alert from './Alert';\nimport SignalOff from './SignalOff';\nimport Folder from './Folder';\nimport ScrollSync from './ScrollSync';\nimport Printer from './Printer';\nimport Undo from './Undo';\nimport Redo from './Redo';\nimport ContentSave from './ContentSave';\nimport Message from './Message';\nimport History from './History';\nimport Database from './Database';\nimport Magnify from './Magnify';\nimport FormatListChecks from './FormatListChecks';\nimport CheckCircle from './CheckCircle';\nimport ContentCopy from './ContentCopy';\nimport Key from './Key';\nimport DotsHorizontal from './DotsHorizontal';\nimport Seal from './Seal';\n\nVue.component('iconProvider', Provider);\nVue.component('iconFormatBold', FormatBold);\nVue.component('iconFormatItalic', FormatItalic);\nVue.component('iconFormatQuoteClose', FormatQuoteClose);\nVue.component('iconLinkVariant', LinkVariant);\nVue.component('iconFileImage', FileImage);\nVue.component('iconTable', Table);\nVue.component('iconFormatListNumbers', FormatListNumbers);\nVue.component('iconFormatListBulleted', FormatListBulleted);\nVue.component('iconFormatSize', FormatSize);\nVue.component('iconFormatStrikethrough', FormatStrikethrough);\nVue.component('iconStatusBar', StatusBar);\nVue.component('iconNavigationBar', NavigationBar);\nVue.component('iconSidePreview', SidePreview);\nVue.component('iconEye', Eye);\nVue.component('iconSettings', Settings);\nVue.component('iconFilePlus', FilePlus);\nVue.component('iconFileMultiple', FileMultiple);\nVue.component('iconFolderPlus', FolderPlus);\nVue.component('iconDelete', Delete);\nVue.component('iconClose', Close);\nVue.component('iconPen', Pen);\nVue.component('iconTarget', Target);\nVue.component('iconArrowLeft', ArrowLeft);\nVue.component('iconHelpCircle', HelpCircle);\nVue.component('iconToc', Toc);\nVue.component('iconLogin', Login);\nVue.component('iconLogout', Logout);\nVue.component('iconSync', Sync);\nVue.component('iconSyncOff', SyncOff);\nVue.component('iconUpload', Upload);\nVue.component('iconViewList', ViewList);\nVue.component('iconDownload', Download);\nVue.component('iconCodeTags', CodeTags);\nVue.component('iconCodeBraces', CodeBraces);\nVue.component('iconOpenInNew', OpenInNew);\nVue.component('iconInformation', Information);\nVue.component('iconAlert', Alert);\nVue.component('iconSignalOff', SignalOff);\nVue.component('iconFolder', Folder);\nVue.component('iconScrollSync', ScrollSync);\nVue.component('iconPrinter', Printer);\nVue.component('iconUndo', Undo);\nVue.component('iconRedo', Redo);\nVue.component('iconContentSave', ContentSave);\nVue.component('iconMessage', Message);\nVue.component('iconHistory', History);\nVue.component('iconDatabase', Database);\nVue.component('iconMagnify', Magnify);\nVue.component('iconFormatListChecks', FormatListChecks);\nVue.component('iconCheckCircle', CheckCircle);\nVue.component('iconContentCopy', ContentCopy);\nVue.component('iconKey', Key);\nVue.component('iconDotsHorizontal', DotsHorizontal);\nVue.component('iconSeal', Seal);\n"
  },
  {
    "path": "src/index.js",
    "content": "import Vue from 'vue';\nimport 'babel-polyfill';\nimport 'indexeddbshim/dist/indexeddbshim';\nimport * as OfflinePluginRuntime from 'offline-plugin/runtime';\nimport './extensions';\nimport './services/optional';\nimport './icons';\nimport App from './components/App';\nimport store from './store';\nimport localDbSvc from './services/localDbSvc';\n\nif (!indexedDB) {\n  throw new Error('Your browser is not supported. Please upgrade to the latest version.');\n}\n\nOfflinePluginRuntime.install({\n  onUpdateReady: () => {\n    // Tells to new SW to take control immediately\n    OfflinePluginRuntime.applyUpdate();\n  },\n  onUpdated: async () => {\n    if (!store.state.light) {\n      await localDbSvc.sync();\n      localStorage.updated = true;\n      // Reload the webpage to load into the new version\n      window.location.reload();\n    }\n  },\n});\n\nif (localStorage.updated) {\n  store.dispatch('notification/info', 'StackEdit has just updated itself!');\n  setTimeout(() => localStorage.removeItem('updated'), 2000);\n}\n\nif (!localStorage.installPrompted) {\n  window.addEventListener('beforeinstallprompt', async (promptEvent) => {\n    // Prevent Chrome 67 and earlier from automatically showing the prompt\n    promptEvent.preventDefault();\n\n    try {\n      await store.dispatch('notification/confirm', 'Add StackEdit to your home screen?');\n      promptEvent.prompt();\n      await promptEvent.userChoice;\n    } catch (err) {\n      // Cancel\n    }\n    localStorage.installPrompted = true;\n  });\n}\n\nVue.config.productionTip = false;\n\n/* eslint-disable no-new */\nnew Vue({\n  el: '#app',\n  store,\n  render: h => h(App),\n});\n"
  },
  {
    "path": "src/libs/clunderscore.js",
    "content": "var arrayProperties = {}\nvar liveCollectionProperties = {}\nvar functionProperties = {}\nvar objectProperties = {}\nvar slice = Array.prototype.slice\n\narrayProperties.cl_each = function (cb) {\n    var i = 0\n    var length = this.length\n    for (; i < length; i++) {\n        cb(this[i], i, this)\n    }\n}\n\narrayProperties.cl_map = function (cb) {\n    var i = 0\n    var length = this.length\n    var result = Array(length)\n    for (; i < length; i++) {\n        result[i] = cb(this[i], i, this)\n    }\n    return result\n}\n\narrayProperties.cl_reduce = function (cb, memo) {\n    var i = 0\n    var length = this.length\n    for (; i < length; i++) {\n        memo = cb(memo, this[i], i, this)\n    }\n    return memo\n}\n\narrayProperties.cl_some = function (cb) {\n    var i = 0\n    var length = this.length\n    for (; i < length; i++) {\n        if (cb(this[i], i, this)) {\n            return true\n        }\n    }\n}\n\narrayProperties.cl_filter = function (cb) {\n    var i = 0\n    var length = this.length\n    var result = []\n    for (; i < length; i++) {\n        cb(this[i], i, this) && result.push(this[i])\n    }\n    return result\n}\n\nliveCollectionProperties.cl_each = function (cb) {\n    slice.call(this).cl_each(cb)\n}\n\nliveCollectionProperties.cl_map = function (cb) {\n    return slice.call(this).cl_map(cb)\n}\n\nliveCollectionProperties.cl_filter = function (cb) {\n    return slice.call(this).cl_filter(cb)\n}\n\nliveCollectionProperties.cl_reduce = function (cb, memo) {\n    return slice.call(this).cl_reduce(cb, memo)\n}\n\nfunctionProperties.cl_bind = function (context) {\n    var self = this\n    var args = slice.call(arguments, 1)\n    context = context || null\n    return args.length\n        ? function () {\n            return arguments.length\n                ? self.apply(context, args.concat(slice.call(arguments)))\n                : self.apply(context, args)\n        }\n        : function () {\n            return arguments.length\n                ? self.apply(context, arguments)\n                : self.call(context)\n        }\n}\n\nobjectProperties.cl_each = function (cb) {\n    var i = 0\n    var keys = Object.keys(this)\n    var length = keys.length\n    for (; i < length; i++) {\n        cb(this[keys[i]], keys[i], this)\n    }\n}\n\nobjectProperties.cl_map = function (cb) {\n    var i = 0\n    var keys = Object.keys(this)\n    var length = keys.length\n    var result = Array(length)\n    for (; i < length; i++) {\n        result[i] = cb(this[keys[i]], keys[i], this)\n    }\n    return result\n}\n\nobjectProperties.cl_reduce = function (cb, memo) {\n    var i = 0\n    var keys = Object.keys(this)\n    var length = keys.length\n    for (; i < length; i++) {\n        memo = cb(memo, this[keys[i]], keys[i], this)\n    }\n    return memo\n}\n\nobjectProperties.cl_some = function (cb) {\n    var i = 0\n    var keys = Object.keys(this)\n    var length = keys.length\n    for (; i < length; i++) {\n        if (cb(this[keys[i]], keys[i], this)) {\n            return true\n        }\n    }\n}\n\nobjectProperties.cl_extend = function (obj) {\n    if (obj) {\n        var i = 0\n        var keys = Object.keys(obj)\n        var length = keys.length\n        for (; i < length; i++) {\n            this[keys[i]] = obj[keys[i]]\n        }\n    }\n    return this\n}\n\nfunction build(properties) {\n    return objectProperties.cl_reduce.call(properties, function (memo, value, key) {\n        memo[key] = {\n            value: value,\n            configurable: true\n        }\n        return memo\n    }, {})\n}\n\narrayProperties = build(arrayProperties)\nliveCollectionProperties = build(liveCollectionProperties)\nfunctionProperties = build(functionProperties)\nobjectProperties = build(objectProperties)\n\n/* eslint-disable no-extend-native */\nObject.defineProperties(Array.prototype, arrayProperties)\nObject.defineProperties(Int8Array.prototype, arrayProperties)\nObject.defineProperties(Uint8Array.prototype, arrayProperties)\nObject.defineProperties(Uint8ClampedArray.prototype, arrayProperties)\nObject.defineProperties(Int16Array.prototype, arrayProperties)\nObject.defineProperties(Uint16Array.prototype, arrayProperties)\nObject.defineProperties(Int32Array.prototype, arrayProperties)\nObject.defineProperties(Uint32Array.prototype, arrayProperties)\nObject.defineProperties(Float32Array.prototype, arrayProperties)\nObject.defineProperties(Float64Array.prototype, arrayProperties)\nObject.defineProperties(Function.prototype, functionProperties)\nObject.defineProperties(Object.prototype, objectProperties)\nif (typeof window !== 'undefined') {\n    Object.defineProperties(HTMLCollection.prototype, liveCollectionProperties)\n    Object.defineProperties(NodeList.prototype, liveCollectionProperties)\n}\n"
  },
  {
    "path": "src/libs/htmlSanitizer.js",
    "content": "const aHrefSanitizationWhitelist = /^\\s*(https?|ftp|mailto|tel|file):/;\nconst imgSrcSanitizationWhitelist = /^\\s*((https?|ftp|file|blob):|data:image\\/)/;\n\nconst urlParsingNode = window.document.createElement('a');\n\nfunction sanitizeUri(uri, isImage) {\n  const regex = isImage ? imgSrcSanitizationWhitelist : aHrefSanitizationWhitelist;\n  urlParsingNode.setAttribute('href', uri);\n  const normalizedVal = urlParsingNode.href;\n  if (normalizedVal !== '' && !normalizedVal.match(regex)) {\n    return `unsafe:${normalizedVal}`;\n  }\n  return uri;\n}\n\nvar buf;\n\n/* jshint -W083 */\n\n// Regular Expressions for parsing tags and attributes\nvar START_TAG_REGEXP =\n  /^<((?:[a-zA-Z])[\\w:-]*)((?:\\s+[\\w:-]+(?:\\s*=\\s*(?:(?:\"[^\"]*\")|(?:'[^']*')|[^>\\s]+))?)*)\\s*(\\/?)\\s*(>?)/,\n  END_TAG_REGEXP = /^<\\/\\s*([\\w:-]+)[^>]*>/,\n  ATTR_REGEXP = /([\\w:-]+)(?:\\s*=\\s*(?:(?:\"((?:[^\"])*)\")|(?:'((?:[^'])*)')|([^>\\s]+)))?/g,\n  BEGIN_TAG_REGEXP = /^</,\n  BEGING_END_TAGE_REGEXP = /^<\\//,\n  COMMENT_REGEXP = /<!--(.*?)-->/g,\n  DOCTYPE_REGEXP = /<!DOCTYPE([^>]*?)>/i,\n  CDATA_REGEXP = /<!\\[CDATA\\[(.*?)]]>/g,\n  SURROGATE_PAIR_REGEXP = /[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]/g,\n  // Match everything outside of normal chars and \" (quote character)\n  NON_ALPHANUMERIC_REGEXP = /([^\\#-~| |!])/g;\n\n\n// Good source of info about elements and attributes\n// http://dev.w3.org/html5/spec/Overview.html#semantics\n// http://simon.html5.org/html-elements\n\n// Safe Void Elements - HTML5\n// http://dev.w3.org/html5/spec/Overview.html#void-elements\nvar voidElements = makeMap(\"area,br,col,hr,img,wbr\");\n\n// Elements that you can, intentionally, leave open (and which close themselves)\n// http://dev.w3.org/html5/spec/Overview.html#optional-tags\nvar optionalEndTagBlockElements = makeMap(\"colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr\"),\n  optionalEndTagInlineElements = makeMap(\"rp,rt\"),\n  optionalEndTagElements = {\n    ...optionalEndTagInlineElements,\n    ...optionalEndTagBlockElements,\n  };\n\n// Safe Block Elements - HTML5\nvar blockElements = {\n  ...optionalEndTagBlockElements,\n  ...makeMap(\"address,article,\" +\n  \"aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,\" +\n  \"h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,script,section,table,ul\")\n};\n\n// benweet: Add iframe\nblockElements.iframe = true;\n\n// Inline Elements - HTML5\nvar inlineElements = {\n  ...optionalEndTagInlineElements,\n  ...makeMap(\"a,abbr,acronym,b,\" +\n    \"bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s,\" +\n    \"samp,small,span,strike,strong,sub,sup,time,tt,u,var\")\n};\n\n// SVG Elements\n// https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Elements\n// Note: the elements animate,animateColor,animateMotion,animateTransform,set are intentionally omitted.\n// They can potentially allow for arbitrary javascript to be executed. See #11290\nvar svgElements = makeMap(\"circle,defs,desc,ellipse,font-face,font-face-name,font-face-src,g,glyph,\" +\n  \"hkern,image,linearGradient,line,marker,metadata,missing-glyph,mpath,path,polygon,polyline,\" +\n  \"radialGradient,rect,stop,svg,switch,text,title,tspan,use\");\n\n// Special Elements (can contain anything)\nvar specialElements = makeMap(\"script,style\");\n\nvar validElements = {\n  ...voidElements,\n  ...blockElements,\n  ...inlineElements,\n  ...optionalEndTagElements,\n  ...svgElements,\n};\n\n//Attributes that have href and hence need to be sanitized\nvar uriAttrs = makeMap(\"background,cite,href,longdesc,src,usemap,xlink:href\");\n\nvar htmlAttrs = makeMap('abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,' +\n  'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,' +\n  'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,' +\n  'scope,scrolling,shape,size,span,start,summary,tabindex,target,title,type,' +\n  'valign,value,vspace,width');\n\n// SVG attributes (without \"id\" and \"name\" attributes)\n// https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Attributes\nvar svgAttrs = makeMap('accent-height,accumulate,additive,alphabetic,arabic-form,ascent,' +\n  'baseProfile,bbox,begin,by,calcMode,cap-height,class,color,color-rendering,content,' +\n  'cx,cy,d,dx,dy,descent,display,dur,end,fill,fill-rule,font-family,font-size,font-stretch,' +\n  'font-style,font-variant,font-weight,from,fx,fy,g1,g2,glyph-name,gradientUnits,hanging,' +\n  'height,horiz-adv-x,horiz-origin-x,ideographic,k,keyPoints,keySplines,keyTimes,lang,' +\n  'marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mathematical,' +\n  'max,min,offset,opacity,orient,origin,overline-position,overline-thickness,panose-1,' +\n  'path,pathLength,points,preserveAspectRatio,r,refX,refY,repeatCount,repeatDur,' +\n  'requiredExtensions,requiredFeatures,restart,rotate,rx,ry,slope,stemh,stemv,stop-color,' +\n  'stop-opacity,strikethrough-position,strikethrough-thickness,stroke,stroke-dasharray,' +\n  'stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,' +\n  'stroke-width,systemLanguage,target,text-anchor,to,transform,type,u1,u2,underline-position,' +\n  'underline-thickness,unicode,unicode-range,units-per-em,values,version,viewBox,visibility,' +\n  'width,widths,x,x-height,x1,x2,xlink:actuate,xlink:arcrole,xlink:role,xlink:show,xlink:title,' +\n  'xlink:type,xml:base,xml:lang,xml:space,xmlns,xmlns:xlink,y,y1,y2,zoomAndPan', true);\n\nvar validAttrs = {\n  ...uriAttrs,\n  ...svgAttrs,\n  ...htmlAttrs,\n};\n\n// benweet: Add id and allowfullscreen (YouTube iframe)\nvalidAttrs.id = true;\nvalidAttrs.allowfullscreen = true;\n\nfunction makeMap(str, lowercaseKeys) {\n  var obj = {},\n    items = str.split(','),\n    i;\n  for (i = 0; i < items.length; i++) {\n    obj[lowercaseKeys ? items[i].toLowerCase() : items[i]] = true;\n  }\n  return obj;\n}\n\n\n/**\n * @example\n * htmlParser(htmlString, {\n *     start: function(tag, attrs, unary) {},\n *     end: function(tag) {},\n *     chars: function(text) {},\n *     comment: function(text) {}\n * });\n *\n * @param {string} html string\n * @param {object} handler\n */\nfunction htmlParser(html, handler) {\n  if (typeof html !== 'string') {\n    if (html === null || typeof html === 'undefined') {\n      html = '';\n    } else {\n      html = '' + html;\n    }\n  }\n  var index, chars, match, stack = [],\n    last = html,\n    text;\n  stack.last = function () {\n    return stack[stack.length - 1];\n  };\n\n  while (html) {\n    text = '';\n    chars = true;\n\n    // Make sure we're not in a script or style element\n    if (!stack.last() || !specialElements[stack.last()]) {\n\n      // Comment\n      if (html.indexOf(\"<!--\") === 0) {\n        // comments containing -- are not allowed unless they terminate the comment\n        index = html.indexOf(\"--\", 4);\n\n        if (index >= 0 && html.lastIndexOf(\"-->\", index) === index) {\n          if (handler.comment) handler.comment(html.substring(4, index));\n          html = html.substring(index + 3);\n          chars = false;\n        }\n        // DOCTYPE\n      } else if (DOCTYPE_REGEXP.test(html)) {\n        match = html.match(DOCTYPE_REGEXP);\n\n        if (match) {\n          html = html.replace(match[0], '');\n          chars = false;\n        }\n        // end tag\n      } else if (BEGING_END_TAGE_REGEXP.test(html)) {\n        match = html.match(END_TAG_REGEXP);\n\n        if (match) {\n          html = html.substring(match[0].length);\n          match[0].replace(END_TAG_REGEXP, parseEndTag);\n          chars = false;\n        }\n\n        // start tag\n      } else if (BEGIN_TAG_REGEXP.test(html)) {\n        match = html.match(START_TAG_REGEXP);\n\n        if (match) {\n          // We only have a valid start-tag if there is a '>'.\n          if (match[4]) {\n            html = html.substring(match[0].length);\n            match[0].replace(START_TAG_REGEXP, parseStartTag);\n          }\n          chars = false;\n        } else {\n          // no ending tag found --- this piece should be encoded as an entity.\n          text += '<';\n          html = html.substring(1);\n        }\n      }\n\n      if (chars) {\n        index = html.indexOf(\"<\");\n\n        text += index < 0 ? html : html.substring(0, index);\n        html = index < 0 ? \"\" : html.substring(index);\n\n        if (handler.chars) handler.chars(decodeEntities(text));\n      }\n\n    } else {\n      // IE versions 9 and 10 do not understand the regex '[^]', so using a workaround with [\\W\\w].\n      html = html.replace(new RegExp(\"([\\\\W\\\\w]*)<\\\\s*\\\\/\\\\s*\" + stack.last() + \"[^>]*>\", 'i'),\n        function (all, text) {\n          text = text.replace(COMMENT_REGEXP, \"$1\").replace(CDATA_REGEXP, \"$1\");\n\n          if (handler.chars) handler.chars(decodeEntities(text));\n\n          return \"\";\n        });\n\n      parseEndTag(\"\", stack.last());\n    }\n\n    if (html == last) {\n      // benweet\n      // throw $sanitizeMinErr('badparse', \"The sanitizer was unable to parse the following block \" +\n      // \t\"of html: {0}\", html);\n      stack.reverse();\n      return stack.cl_each(function (tag) {\n        buf.push('</');\n        buf.push(tag);\n        buf.push('>');\n      });\n    }\n    last = html;\n  }\n\n  // Clean up any remaining tags\n  parseEndTag();\n\n  function parseStartTag(tag, tagName, rest, unary) {\n    tagName = tagName && tagName.toLowerCase();\n    if (blockElements[tagName]) {\n      while (stack.last() && inlineElements[stack.last()]) {\n        parseEndTag(\"\", stack.last());\n      }\n    }\n\n    if (optionalEndTagElements[tagName] && stack.last() == tagName) {\n      parseEndTag(\"\", tagName);\n    }\n\n    unary = voidElements[tagName] || !!unary;\n\n    if (!unary) {\n      stack.push(tagName);\n    }\n\n    var attrs = {};\n\n    rest.replace(ATTR_REGEXP,\n      function (match, name, doubleQuotedValue, singleQuotedValue, unquotedValue) {\n        var value = doubleQuotedValue || singleQuotedValue || unquotedValue || '';\n\n        attrs[name] = decodeEntities(value);\n      });\n    if (handler.start) handler.start(tagName, attrs, unary);\n  }\n\n  function parseEndTag(tag, tagName) {\n    var pos = 0,\n      i;\n    tagName = tagName && tagName.toLowerCase();\n    if (tagName) {\n      // Find the closest opened tag of the same type\n      for (pos = stack.length - 1; pos >= 0; pos--) {\n        if (stack[pos] == tagName) break;\n      }\n    }\n\n    if (pos >= 0) {\n      // Close all the open elements, up the stack\n      for (i = stack.length - 1; i >= pos; i--)\n        if (handler.end) handler.end(stack[i]);\n\n      // Remove the open elements from the stack\n      stack.length = pos;\n    }\n  }\n}\n\nvar hiddenPre = document.createElement(\"pre\");\n/**\n * decodes all entities into regular string\n * @param value\n * @returns {string} A string with decoded entities.\n */\nfunction decodeEntities(value) {\n  if (!value) {\n    return '';\n  }\n\n  hiddenPre.innerHTML = value.replace(/</g, \"&lt;\");\n  // innerText depends on styling as it doesn't display hidden elements.\n  // Therefore, it's better to use textContent not to cause unnecessary reflows.\n  return hiddenPre.textContent;\n}\n\n/**\n * Escapes all potentially dangerous characters, so that the\n * resulting string can be safely inserted into attribute or\n * element text.\n * @param value\n * @returns {string} escaped text\n */\nfunction encodeEntities(value) {\n  return value.\n    replace(/&/g, '&amp;').\n    replace(SURROGATE_PAIR_REGEXP, function (value) {\n      var hi = value.charCodeAt(0);\n      var low = value.charCodeAt(1);\n      return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';';\n    }).\n    replace(NON_ALPHANUMERIC_REGEXP, function (value) {\n      return '&#' + value.charCodeAt(0) + ';';\n    }).\n    replace(/</g, '&lt;').\n    replace(/>/g, '&gt;');\n}\n\n/**\n * create an HTML/XML writer which writes to buffer\n * @param {Array} buf use buf.jain('') to get out sanitized html string\n * @returns {object} in the form of {\n *     start: function(tag, attrs, unary) {},\n *     end: function(tag) {},\n *     chars: function(text) {},\n *     comment: function(text) {}\n * }\n */\nfunction htmlSanitizeWriter(buf, uriValidator) {\n  var ignore = false;\n  var out = buf.push.bind(buf);\n  return {\n    start: function (tag, attrs, unary) {\n      tag = tag && tag.toLowerCase();\n      if (!ignore && specialElements[tag]) {\n        ignore = tag;\n      }\n      if (!ignore && validElements[tag] === true) {\n        out('<');\n        out(tag);\n        Object.keys(attrs).forEach(function (key) {\n          var value = attrs[key];\n          var lkey = key && key.toLowerCase();\n          var isImage = (tag === 'img' && lkey === 'src') || (lkey === 'background');\n          if (validAttrs[lkey] === true &&\n            (uriAttrs[lkey] !== true || uriValidator(value, isImage))) {\n            out(' ');\n            out(key);\n            out('=\"');\n            out(encodeEntities(value));\n            out('\"');\n          }\n        });\n        out(unary ? '/>' : '>');\n      }\n    },\n    end: function (tag) {\n      tag = tag && tag.toLowerCase();\n      if (!ignore && validElements[tag] === true) {\n        out('</');\n        out(tag);\n        out('>');\n      }\n      if (tag == ignore) {\n        ignore = false;\n      }\n    },\n    chars: function (chars) {\n      if (!ignore) {\n        out(encodeEntities(chars));\n      }\n    },\n    comment: function (comment) {\n      if (!ignore) {\n        out('<!--');\n        out(encodeEntities(comment));\n        out('-->');\n      }\n    }\n  };\n}\n\nfunction sanitizeHtml(html) {\n  buf = [];\n  htmlParser(html, htmlSanitizeWriter(buf, function (uri, isImage) {\n    return !/^unsafe/.test(sanitizeUri(uri, isImage));\n  }));\n  return buf.join('');\n}\n\n\nexport default {\n  sanitizeHtml,\n  sanitizeUri,\n}\n"
  },
  {
    "path": "src/libs/pagedown.js",
    "content": "var util = {},\n  re = window.RegExp,\n  SETTINGS = {\n    lineLength: 72\n  };\n\nvar defaultsStrings = {\n  bold: \"Strong <strong> Ctrl/Cmd+B\",\n  boldexample: \"strong text\",\n\n  italic: \"Emphasis <em> Ctrl/Cmd+I\",\n  italicexample: \"emphasized text\",\n\n  strikethrough: \"Strikethrough <s> Ctrl/Cmd+I\",\n  strikethroughexample: \"strikethrough text\",\n\n  link: \"Hyperlink <a> Ctrl/Cmd+L\",\n  linkdescription: \"enter link description here\",\n  linkdialog: \"<p><b>Insert Hyperlink</b></p><p>http://example.com/ \\\"optional title\\\"</p>\",\n\n  quote: \"Blockquote <blockquote> Ctrl/Cmd+Q\",\n  quoteexample: \"Blockquote\",\n\n  code: \"Code Sample <pre><code> Ctrl/Cmd+K\",\n  codeexample: \"enter code here\",\n\n  image: \"Image <img> Ctrl/Cmd+G\",\n  imagedescription: \"enter image description here\",\n  imagedialog: \"<p><b>Insert Image</b></p><p>http://example.com/images/diagram.jpg \\\"optional title\\\"<br><br>Need <a href='http://www.google.com/search?q=free+image+hosting' target='_blank'>free image hosting?</a></p>\",\n\n  olist: \"Numbered List <ol> Ctrl/Cmd+O\",\n  ulist: \"Bulleted List <ul> Ctrl/Cmd+U\",\n  litem: \"List item\",\n\n  heading: \"Heading <h1>/<h2> Ctrl/Cmd+H\",\n  headingexample: \"Heading\",\n\n  hr: \"Horizontal Rule <hr> Ctrl/Cmd+R\",\n\n  undo: \"Undo - Ctrl/Cmd+Z\",\n  redo: \"Redo - Ctrl/Cmd+Y\",\n\n  help: \"Markdown Editing Help\"\n};\n\n// options, if given, can have the following properties:\n//   options.helpButton = { handler: yourEventHandler }\n//   options.strings = { italicexample: \"slanted text\" }\n// `yourEventHandler` is the click handler for the help button.\n// If `options.helpButton` isn't given, not help button is created.\n// `options.strings` can have any or all of the same properties as\n// `defaultStrings` above, so you can just override some string displayed\n// to the user on a case-by-case basis, or translate all strings to\n// a different language.\n//\n// For backwards compatibility reasons, the `options` argument can also\n// be just the `helpButton` object, and `strings.help` can also be set via\n// `helpButton.title`. This should be considered legacy.\n//\n// The constructed editor object has the methods:\n// - getConverter() returns the markdown converter object that was passed to the constructor\n// - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op.\n// - refreshPreview() forces the preview to be updated. This method is only available after run() was called.\nfunction Pagedown(options) {\n\n  options = options || {};\n\n  if (typeof options.handler === \"function\") { //backwards compatible behavior\n    options = {\n      helpButton: options\n    };\n  }\n  options.strings = options.strings || {};\n  var getString = function (identifier) {\n    return options.strings[identifier] || defaultsStrings[identifier];\n  };\n\n  function identity(x) {\n    return x;\n  }\n\n  function returnFalse() {\n    return false;\n  }\n\n  function HookCollection() { }\n  HookCollection.prototype = {\n\n    chain: function (hookname, func) {\n      var original = this[hookname];\n      if (!original) {\n        throw new Error(\"unknown hook \" + hookname);\n      }\n\n      if (original === identity) {\n        this[hookname] = func;\n      } else {\n        this[hookname] = function () {\n          var args = Array.prototype.slice.call(arguments, 0);\n          args[0] = original.apply(null, args);\n          return func.apply(null, args);\n        };\n      }\n    },\n    set: function (hookname, func) {\n      if (!this[hookname]) {\n        throw new Error(\"unknown hook \" + hookname);\n      }\n      this[hookname] = func;\n    },\n    addNoop: function (hookname) {\n      this[hookname] = identity;\n    },\n    addFalse: function (hookname) {\n      this[hookname] = returnFalse;\n    }\n  };\n\n  var hooks = this.hooks = new HookCollection();\n  hooks.addNoop(\"onPreviewRefresh\"); // called with no arguments after the preview has been refreshed\n  hooks.addNoop(\"postBlockquoteCreation\"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text\n  hooks.addFalse(\"insertImageDialog\");\n  /* called with one parameter: a callback to be called with the URL of the image. If the application creates\n   * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen\n   * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used.\n   */\n  hooks.addFalse(\"insertLinkDialog\");\n\n  var that = this,\n    input;\n\n  this.run = function () {\n    if (input)\n      return; // already initialized\n\n    input = options.input;\n    var commandManager = new CommandManager(hooks, getString);\n    var uiManager;\n\n    uiManager = new UIManager(input, commandManager);\n\n    that.uiManager = uiManager;\n  };\n\n}\n\n// before: contains all the text in the input box BEFORE the selection.\n// after: contains all the text in the input box AFTER the selection.\nfunction Chunks() { }\n\n// startRegex: a regular expression to find the start tag\n// endRegex: a regular expresssion to find the end tag\nChunks.prototype.findTags = function (startRegex, endRegex) {\n\n  var chunkObj = this;\n  var regex;\n\n  if (startRegex) {\n\n    regex = util.extendRegExp(startRegex, \"\", \"$\");\n\n    this.before = this.before.replace(regex,\n      function (match) {\n        chunkObj.startTag = chunkObj.startTag + match;\n        return \"\";\n      });\n\n    regex = util.extendRegExp(startRegex, \"^\", \"\");\n\n    this.selection = this.selection.replace(regex,\n      function (match) {\n        chunkObj.startTag = chunkObj.startTag + match;\n        return \"\";\n      });\n  }\n\n  if (endRegex) {\n\n    regex = util.extendRegExp(endRegex, \"\", \"$\");\n\n    this.selection = this.selection.replace(regex,\n      function (match) {\n        chunkObj.endTag = match + chunkObj.endTag;\n        return \"\";\n      });\n\n    regex = util.extendRegExp(endRegex, \"^\", \"\");\n\n    this.after = this.after.replace(regex,\n      function (match) {\n        chunkObj.endTag = match + chunkObj.endTag;\n        return \"\";\n      });\n  }\n};\n\n// If remove is false, the whitespace is transferred\n// to the before/after regions.\n//\n// If remove is true, the whitespace disappears.\nChunks.prototype.trimWhitespace = function (remove) {\n  var beforeReplacer, afterReplacer, that = this;\n  if (remove) {\n    beforeReplacer = afterReplacer = \"\";\n  } else {\n    beforeReplacer = function (s) {\n      that.before += s;\n      return \"\";\n    };\n    afterReplacer = function (s) {\n      that.after = s + that.after;\n      return \"\";\n    };\n  }\n\n  this.selection = this.selection.replace(/^(\\s*)/, beforeReplacer).replace(/(\\s*)$/, afterReplacer);\n};\n\n\nChunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) {\n\n  if (nLinesBefore === undefined) {\n    nLinesBefore = 1;\n  }\n\n  if (nLinesAfter === undefined) {\n    nLinesAfter = 1;\n  }\n\n  nLinesBefore++;\n  nLinesAfter++;\n\n  var regexText;\n  var replacementText;\n\n  // chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985\n  if (navigator.userAgent.match(/Chrome/)) {\n    \"X\".match(/()./);\n  }\n\n  this.selection = this.selection.replace(/(^\\n*)/, \"\");\n\n  this.startTag = this.startTag + re.$1;\n\n  this.selection = this.selection.replace(/(\\n*$)/, \"\");\n  this.endTag = this.endTag + re.$1;\n  this.startTag = this.startTag.replace(/(^\\n*)/, \"\");\n  this.before = this.before + re.$1;\n  this.endTag = this.endTag.replace(/(\\n*$)/, \"\");\n  this.after = this.after + re.$1;\n\n  if (this.before) {\n\n    regexText = replacementText = \"\";\n\n    while (nLinesBefore--) {\n      regexText += \"\\\\n?\";\n      replacementText += \"\\n\";\n    }\n\n    if (findExtraNewlines) {\n      regexText = \"\\\\n*\";\n    }\n    this.before = this.before.replace(new re(regexText + \"$\", \"\"), replacementText);\n  }\n\n  if (this.after) {\n\n    regexText = replacementText = \"\";\n\n    while (nLinesAfter--) {\n      regexText += \"\\\\n?\";\n      replacementText += \"\\n\";\n    }\n    if (findExtraNewlines) {\n      regexText = \"\\\\n*\";\n    }\n\n    this.after = this.after.replace(new re(regexText, \"\"), replacementText);\n  }\n};\n\n// end of Chunks\n\n// Converts \\r\\n and \\r to \\n.\nutil.fixEolChars = function (text) {\n  text = text.replace(/\\r\\n/g, \"\\n\");\n  text = text.replace(/\\r/g, \"\\n\");\n  return text;\n};\n\n// Extends a regular expression.  Returns a new RegExp\n// using pre + regex + post as the expression.\n// Used in a few functions where we have a base\n// expression and we want to pre- or append some\n// conditions to it (e.g. adding \"$\" to the end).\n// The flags are unchanged.\n//\n// regex is a RegExp, pre and post are strings.\nutil.extendRegExp = function (regex, pre, post) {\n\n  if (pre === null || pre === undefined) {\n    pre = \"\";\n  }\n  if (post === null || post === undefined) {\n    post = \"\";\n  }\n\n  var pattern = regex.toString();\n  var flags;\n\n  // Replace the flags with empty space and store them.\n  pattern = pattern.replace(/\\/([gim]*)$/, function (wholeMatch, flagsPart) {\n    flags = flagsPart;\n    return \"\";\n  });\n\n  // Remove the slash delimiters on the regular expression.\n  pattern = pattern.replace(/(^\\/|\\/$)/g, \"\");\n  pattern = pre + pattern + post;\n\n  return new re(pattern, flags);\n};\n\n// The input textarea state/contents.\n// This is used to implement undo/redo by the undo manager.\nfunction TextareaState(input) {\n\n  // Aliases\n  var stateObj = this;\n  var inputArea = input;\n  this.init = function () {\n    this.setInputAreaSelectionStartEnd();\n    this.text = inputArea.getContent();\n  };\n\n  // Sets the selected text in the input box after we've performed an\n  // operation.\n  this.setInputAreaSelection = function () {\n    inputArea.focus();\n    inputArea.setSelection(stateObj.start, stateObj.end);\n  };\n\n  this.setInputAreaSelectionStartEnd = function () {\n    stateObj.start = Math.min(\n      inputArea.selectionMgr.selectionStart,\n      inputArea.selectionMgr.selectionEnd\n    );\n    stateObj.end = Math.max(\n      inputArea.selectionMgr.selectionStart,\n      inputArea.selectionMgr.selectionEnd\n    );\n  };\n\n  // Restore this state into the input area.\n  this.restore = function () {\n\n    if (stateObj.text !== undefined && stateObj.text != inputArea.getContent()) {\n      inputArea.setContent(stateObj.text);\n    }\n    this.setInputAreaSelection();\n  };\n\n  // Gets a collection of HTML chunks from the inptut textarea.\n  this.getChunks = function () {\n\n    var chunk = new Chunks();\n    chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start));\n    chunk.startTag = \"\";\n    chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end));\n    chunk.endTag = \"\";\n    chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end));\n\n    return chunk;\n  };\n\n  // Sets the TextareaState properties given a chunk of markdown.\n  this.setChunks = function (chunk) {\n\n    chunk.before = chunk.before + chunk.startTag;\n    chunk.after = chunk.endTag + chunk.after;\n\n    this.start = chunk.before.length;\n    this.end = chunk.before.length + chunk.selection.length;\n    this.text = chunk.before + chunk.selection + chunk.after;\n  };\n  this.init();\n}\n\nfunction UIManager(input, commandManager) {\n\n  var inputBox = input,\n    buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements.\n\n  makeSpritedButtonRow();\n\n  // Perform the button's action.\n  function doClick(buttonName) {\n    var button = buttons[buttonName];\n    if (!button) {\n      return;\n    }\n\n    inputBox.focus();\n    var linkOrImage = button === buttons.link || button.id === buttons.image;\n\n    var state = new TextareaState(input);\n\n    if (!state) {\n      return;\n    }\n\n    var chunks = state.getChunks();\n\n    // Some commands launch a \"modal\" prompt dialog.  Javascript\n    // can't really make a modal dialog box and the WMD code\n    // will continue to execute while the dialog is displayed.\n    // This prevents the dialog pattern I'm used to and means\n    // I can't do something like this:\n    //\n    // var link = CreateLinkDialog();\n    // makeMarkdownLink(link);\n    //\n    // Instead of this straightforward method of handling a\n    // dialog I have to pass any code which would execute\n    // after the dialog is dismissed (e.g. link creation)\n    // in a function parameter.\n    //\n    // Yes this is awkward and I think it sucks, but there's\n    // no real workaround.  Only the image and link code\n    // create dialogs and require the function pointers.\n    var fixupInputArea = function () {\n\n      inputBox.focus();\n\n      if (chunks) {\n        state.setChunks(chunks);\n      }\n\n      state.restore();\n    };\n\n    var noCleanup = button(chunks, fixupInputArea);\n\n    if (!noCleanup) {\n      fixupInputArea();\n      if (!linkOrImage) {\n        inputBox.adjustCursorPosition();\n      }\n    }\n  }\n\n  function bindCommand(method) {\n    if (typeof method === \"string\")\n      method = commandManager[method];\n    return function () {\n      method.apply(commandManager, arguments);\n    };\n  }\n\n  function makeSpritedButtonRow() {\n\n    buttons.bold = bindCommand(\"doBold\");\n    buttons.italic = bindCommand(\"doItalic\");\n    buttons.strikethrough = bindCommand(\"doStrikethrough\");\n    buttons.link = bindCommand(function (chunk, postProcessing) {\n      return this.doLinkOrImage(chunk, postProcessing, false);\n    });\n    buttons.quote = bindCommand(\"doBlockquote\");\n    buttons.code = bindCommand(\"doCode\");\n    buttons.image = bindCommand(function (chunk, postProcessing) {\n      return this.doLinkOrImage(chunk, postProcessing, true);\n    });\n    buttons.olist = bindCommand(function (chunk, postProcessing) {\n      this.doList(chunk, postProcessing, true);\n    });\n    buttons.ulist = bindCommand(function (chunk, postProcessing) {\n      this.doList(chunk, postProcessing, false);\n    });\n    buttons.clist = bindCommand(function (chunk, postProcessing) {\n      this.doList(chunk, postProcessing, false, true);\n    });\n    buttons.heading = bindCommand(\"doHeading\");\n    buttons.hr = bindCommand(\"doHorizontalRule\");\n    buttons.table = bindCommand(\"doTable\");\n  }\n\n  this.doClick = doClick;\n\n}\n\nfunction CommandManager(pluginHooks, getString) {\n  this.hooks = pluginHooks;\n  this.getString = getString;\n}\n\nvar commandProto = CommandManager.prototype;\n\n// The markdown symbols - 4 spaces = code, > = blockquote, etc.\ncommandProto.prefixes = \"(?:\\\\s{4,}|\\\\s*>|\\\\s*-\\\\s+|\\\\s*\\\\d+\\\\.|=|\\\\+|-|_|\\\\*|#|\\\\s*\\\\[[^\\n]]+\\\\]:)\";\n\n// Remove markdown symbols from the chunk selection.\ncommandProto.unwrap = function (chunk) {\n  var txt = new re(\"([^\\\\n])\\\\n(?!(\\\\n|\" + this.prefixes + \"))\", \"g\");\n  chunk.selection = chunk.selection.replace(txt, \"$1 $2\");\n};\n\ncommandProto.wrap = function (chunk, len) {\n  this.unwrap(chunk);\n  var regex = new re(\"(.{1,\" + len + \"})( +|$\\\\n?)\", \"gm\"),\n    that = this;\n\n  chunk.selection = chunk.selection.replace(regex, function (line, marked) {\n    if (new re(\"^\" + that.prefixes, \"\").test(line)) {\n      return line;\n    }\n    return marked + \"\\n\";\n  });\n\n  chunk.selection = chunk.selection.replace(/\\s+$/, \"\");\n};\n\ncommandProto.doBold = function (chunk, postProcessing) {\n  return this.doBorI(chunk, postProcessing, 2, this.getString(\"boldexample\"));\n};\n\ncommandProto.doItalic = function (chunk, postProcessing) {\n  return this.doBorI(chunk, postProcessing, 1, this.getString(\"italicexample\"));\n};\n\n// chunk: The selected region that will be enclosed with */**\n// nStars: 1 for italics, 2 for bold\n// insertText: If you just click the button without highlighting text, this gets inserted\ncommandProto.doBorI = function (chunk, postProcessing, nStars, insertText) {\n\n  // Get rid of whitespace and fixup newlines.\n  chunk.trimWhitespace();\n  chunk.selection = chunk.selection.replace(/\\n{2,}/g, \"\\n\");\n\n  // Look for stars before and after.  Is the chunk already marked up?\n  // note that these regex matches cannot fail\n  var starsBefore = /(\\**$)/.exec(chunk.before)[0];\n  var starsAfter = /(^\\**)/.exec(chunk.after)[0];\n\n  var prevStars = Math.min(starsBefore.length, starsAfter.length);\n\n  // Remove stars if we have to since the button acts as a toggle.\n  if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) {\n    chunk.before = chunk.before.replace(re(\"[*]{\" + nStars + \"}$\", \"\"), \"\");\n    chunk.after = chunk.after.replace(re(\"^[*]{\" + nStars + \"}\", \"\"), \"\");\n  } else if (!chunk.selection && starsAfter) {\n    // It's not really clear why this code is necessary.  It just moves\n    // some arbitrary stuff around.\n    chunk.after = chunk.after.replace(/^([*_]*)/, \"\");\n    chunk.before = chunk.before.replace(/(\\s?)$/, \"\");\n    var whitespace = re.$1;\n    chunk.before = chunk.before + starsAfter + whitespace;\n  } else {\n\n    // In most cases, if you don't have any selected text and click the button\n    // you'll get a selected, marked up region with the default text inserted.\n    if (!chunk.selection && !starsAfter) {\n      chunk.selection = insertText;\n    }\n\n    // Add the true markup.\n    var markup = nStars <= 1 ? \"*\" : \"**\"; // shouldn't the test be = ?\n    chunk.before = chunk.before + markup;\n    chunk.after = markup + chunk.after;\n  }\n\n  return;\n};\n\ncommandProto.doStrikethrough = function (chunk, postProcessing) {\n\n  // Get rid of whitespace and fixup newlines.\n  chunk.trimWhitespace();\n  chunk.selection = chunk.selection.replace(/\\n{2,}/g, \"\\n\");\n\n  // Look for stars before and after.  Is the chunk already marked up?\n  // note that these regex matches cannot fail\n  var starsBefore = /(~*$)/.exec(chunk.before)[0];\n  var starsAfter = /(^~*)/.exec(chunk.after)[0];\n\n  var prevStars = Math.min(starsBefore.length, starsAfter.length);\n\n  var nStars = 2;\n\n  // Remove stars if we have to since the button acts as a toggle.\n  if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) {\n    chunk.before = chunk.before.replace(re(\"[~]{\" + nStars + \"}$\", \"\"), \"\");\n    chunk.after = chunk.after.replace(re(\"^[~]{\" + nStars + \"}\", \"\"), \"\");\n  } else if (!chunk.selection && starsAfter) {\n    // It's not really clear why this code is necessary.  It just moves\n    // some arbitrary stuff around.\n    chunk.after = chunk.after.replace(/^(~*)/, \"\");\n    chunk.before = chunk.before.replace(/(\\s?)$/, \"\");\n    var whitespace = re.$1;\n    chunk.before = chunk.before + starsAfter + whitespace;\n  } else {\n\n    // In most cases, if you don't have any selected text and click the button\n    // you'll get a selected, marked up region with the default text inserted.\n    if (!chunk.selection && !starsAfter) {\n      chunk.selection = this.getString(\"strikethroughexample\");\n    }\n\n    // Add the true markup.\n    var markup = \"~~\"; // shouldn't the test be = ?\n    chunk.before = chunk.before + markup;\n    chunk.after = markup + chunk.after;\n  }\n\n  return;\n};\n\ncommandProto.stripLinkDefs = function (text, defsToAdd) {\n\n  text = text.replace(/^[ ]{0,3}\\[(\\d+)\\]:[ \\t]*\\n?[ \\t]*<?(\\S+?)>?[ \\t]*\\n?[ \\t]*(?:(\\n*)[\"(](.+?)[\")][ \\t]*)?(?:\\n+|$)/gm,\n    function (totalMatch, id, link, newlines, title) {\n      defsToAdd[id] = totalMatch.replace(/\\s*$/, \"\");\n      if (newlines) {\n        // Strip the title and return that separately.\n        defsToAdd[id] = totalMatch.replace(/[\"(](.+?)[\")]$/, \"\");\n        return newlines + title;\n      }\n      return \"\";\n    });\n\n  return text;\n};\n\ncommandProto.addLinkDef = function (chunk, linkDef) {\n\n  var refNumber = 0; // The current reference number\n  var defsToAdd = {}; //\n  // Start with a clean slate by removing all previous link definitions.\n  chunk.before = this.stripLinkDefs(chunk.before, defsToAdd);\n  chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd);\n  chunk.after = this.stripLinkDefs(chunk.after, defsToAdd);\n\n  var defs = \"\";\n  var regex = /(\\[)((?:\\[[^\\]]*\\]|[^\\[\\]])*)(\\][ ]?(?:\\n[ ]*)?\\[)(\\d+)(\\])/g;\n\n  var addDefNumber = function (def) {\n    refNumber++;\n    def = def.replace(/^[ ]{0,3}\\[(\\d+)\\]:/, \"  [\" + refNumber + \"]:\");\n    defs += \"\\n\" + def;\n  };\n\n  // note that\n  // a) the recursive call to getLink cannot go infinite, because by definition\n  //    of regex, inner is always a proper substring of wholeMatch, and\n  // b) more than one level of nesting is neither supported by the regex\n  //    nor making a lot of sense (the only use case for nesting is a linked image)\n  var getLink = function (wholeMatch, before, inner, afterInner, id, end) {\n    inner = inner.replace(regex, getLink);\n    if (defsToAdd[id]) {\n      addDefNumber(defsToAdd[id]);\n      return before + inner + afterInner + refNumber + end;\n    }\n    return wholeMatch;\n  };\n\n  chunk.before = chunk.before.replace(regex, getLink);\n\n  if (linkDef) {\n    addDefNumber(linkDef);\n  } else {\n    chunk.selection = chunk.selection.replace(regex, getLink);\n  }\n\n  var refOut = refNumber;\n\n  chunk.after = chunk.after.replace(regex, getLink);\n\n  if (chunk.after) {\n    chunk.after = chunk.after.replace(/\\n*$/, \"\");\n  }\n  if (!chunk.after) {\n    chunk.selection = chunk.selection.replace(/\\n*$/, \"\");\n  }\n\n  chunk.after += \"\\n\\n\" + defs;\n\n  return refOut;\n};\n\n// takes the line as entered into the add link/as image dialog and makes\n// sure the URL and the optinal title are \"nice\".\nfunction properlyEncoded(linkdef) {\n  return linkdef.replace(/^\\s*(.*?)(?:\\s+\"(.+)\")?\\s*$/, function (wholematch, link, title) {\n    link = link.replace(/\\?.*$/, function (querypart) {\n      return querypart.replace(/\\+/g, \" \"); // in the query string, a plus and a space are identical\n    });\n    link = decodeURIComponent(link); // unencode first, to prevent double encoding\n    link = encodeURI(link).replace(/'/g, '%27').replace(/\\(/g, '%28').replace(/\\)/g, '%29');\n    link = link.replace(/\\?.*$/, function (querypart) {\n      return querypart.replace(/\\+/g, \"%2b\"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded\n    });\n    if (title) {\n      title = title.trim ? title.trim() : title.replace(/^\\s*/, \"\").replace(/\\s*$/, \"\");\n      title = title.replace(/\"/g, \"quot;\").replace(/\\(/g, \"&#40;\").replace(/\\)/g, \"&#41;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n    }\n    return title ? link + ' \"' + title + '\"' : link;\n  });\n}\n\ncommandProto.doLinkOrImage = function (chunk, postProcessing, isImage) {\n\n  chunk.trimWhitespace();\n  //chunk.findTags(/\\s*!?\\[/, /\\][ ]?(?:\\n[ ]*)?(\\[.*?\\])?/);\n  chunk.findTags(/\\s*!?\\[/, /\\][ ]?(?:\\n[ ]*)?(\\(.*?\\))?/);\n\n  if (chunk.endTag.length > 1 && chunk.startTag.length > 0) {\n\n    chunk.startTag = chunk.startTag.replace(/!?\\[/, \"\");\n    chunk.endTag = \"\";\n    this.addLinkDef(chunk, null);\n\n  } else {\n\n    // We're moving start and end tag back into the selection, since (as we're in the else block) we're not\n    // *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the\n    // link text. linkEnteredCallback takes care of escaping any brackets.\n    chunk.selection = chunk.startTag + chunk.selection + chunk.endTag;\n    chunk.startTag = chunk.endTag = \"\";\n\n    if (/\\n\\n/.test(chunk.selection)) {\n      this.addLinkDef(chunk, null);\n      return;\n    }\n    var that = this;\n    // The function to be executed when you enter a link and press OK or Cancel.\n    // Marks up the link and adds the ref.\n    var linkEnteredCallback = function (link) {\n\n      if (link !== null) {\n        // (                          $1\n        //     [^\\\\]                  anything that's not a backslash\n        //     (?:\\\\\\\\)*              an even number (this includes zero) of backslashes\n        // )\n        // (?=                        followed by\n        //     [[\\]]                  an opening or closing bracket\n        // )\n        //\n        // In other words, a non-escaped bracket. These have to be escaped now to make sure they\n        // don't count as the end of the link or similar.\n        // Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets),\n        // the bracket in one match may be the \"not a backslash\" character in the next match, so it\n        // should not be consumed by the first match.\n        // The \"prepend a space and finally remove it\" steps makes sure there is a \"not a backslash\" at the\n        // start of the string, so this also works if the selection begins with a bracket. We cannot solve\n        // this by anchoring with ^, because in the case that the selection starts with two brackets, this\n        // would mean a zero-width match at the start. Since zero-width matches advance the string position,\n        // the first bracket could then not act as the \"not a backslash\" for the second.\n        chunk.selection = (\" \" + chunk.selection).replace(/([^\\\\](?:\\\\\\\\)*)(?=[[\\]])/g, \"$1\\\\\").substr(1);\n\n        /*\n        var linkDef = \" [999]: \" + properlyEncoded(link);\n\n        var num = that.addLinkDef(chunk, linkDef);\n        */\n        chunk.startTag = isImage ? \"![\" : \"[\";\n        //chunk.endTag = \"][\" + num + \"]\";\n        chunk.endTag = \"](\" + properlyEncoded(link) + \")\";\n\n        if (!chunk.selection) {\n          if (isImage) {\n            chunk.selection = that.getString(\"imagedescription\");\n          } else {\n            chunk.selection = that.getString(\"linkdescription\");\n          }\n        }\n      }\n      postProcessing();\n    };\n\n    if (isImage) {\n      this.hooks.insertImageDialog(linkEnteredCallback);\n    } else {\n      this.hooks.insertLinkDialog(linkEnteredCallback);\n    }\n    return true;\n  }\n};\n\n// When making a list, hitting shift-enter will put your cursor on the next line\n// at the current indent level.\ncommandProto.doAutoindent = function (chunk) {\n\n  var commandMgr = this,\n    fakeSelection = false;\n\n  chunk.before = chunk.before.replace(/(\\n|^)[ ]{0,3}([*+-]|\\d+[.])[ \\t]*\\n$/, \"\\n\\n\");\n  chunk.before = chunk.before.replace(/(\\n|^)[ ]{0,3}>[ \\t]*\\n$/, \"\\n\\n\");\n  chunk.before = chunk.before.replace(/(\\n|^)[ \\t]+\\n$/, \"\\n\\n\");\n\n  // There's no selection, end the cursor wasn't at the end of the line:\n  // The user wants to split the current list item / code line / blockquote line\n  // (for the latter it doesn't really matter) in two. Temporarily select the\n  // (rest of the) line to achieve this.\n  if (!chunk.selection && !/^[ \\t]*(?:\\n|$)/.test(chunk.after)) {\n    chunk.after = chunk.after.replace(/^[^\\n]*/, function (wholeMatch) {\n      chunk.selection = wholeMatch;\n      return \"\";\n    });\n    fakeSelection = true;\n  }\n\n  if (/(\\n|^)[ ]{0,3}([*+-]|\\d+[.])[ \\t]+.*\\n$/.test(chunk.before)) {\n    if (commandMgr.doList) {\n      commandMgr.doList(chunk);\n    }\n  }\n  if (/(\\n|^)[ ]{0,3}>[ \\t]+.*\\n$/.test(chunk.before)) {\n    if (commandMgr.doBlockquote) {\n      commandMgr.doBlockquote(chunk);\n    }\n  }\n  if (/(\\n|^)(\\t|[ ]{4,}).*\\n$/.test(chunk.before)) {\n    if (commandMgr.doCode) {\n      commandMgr.doCode(chunk);\n    }\n  }\n\n  if (fakeSelection) {\n    chunk.after = chunk.selection + chunk.after;\n    chunk.selection = \"\";\n  }\n};\n\ncommandProto.doBlockquote = function (chunk) {\n\n  chunk.selection = chunk.selection.replace(/^(\\n*)([^\\r]+?)(\\n*)$/,\n    function (totalMatch, newlinesBefore, text, newlinesAfter) {\n      chunk.before += newlinesBefore;\n      chunk.after = newlinesAfter + chunk.after;\n      return text;\n    });\n\n  chunk.before = chunk.before.replace(/(>[ \\t]*)$/,\n    function (totalMatch, blankLine) {\n      chunk.selection = blankLine + chunk.selection;\n      return \"\";\n    });\n\n  chunk.selection = chunk.selection.replace(/^(\\s|>)+$/, \"\");\n  chunk.selection = chunk.selection || this.getString(\"quoteexample\");\n\n  // The original code uses a regular expression to find out how much of the\n  // text *directly before* the selection already was a blockquote:\n\n  /*\n  if (chunk.before) {\n  chunk.before = chunk.before.replace(/\\n?$/, \"\\n\");\n  }\n  chunk.before = chunk.before.replace(/(((\\n|^)(\\n[ \\t]*)*>(.+\\n)*.*)+(\\n[ \\t]*)*$)/,\n  function (totalMatch) {\n  chunk.startTag = totalMatch;\n  return \"\";\n  });\n  */\n\n  // This comes down to:\n  // Go backwards as many lines a possible, such that each line\n  //  a) starts with \">\", or\n  //  b) is almost empty, except for whitespace, or\n  //  c) is preceeded by an unbroken chain of non-empty lines\n  //     leading up to a line that starts with \">\" and at least one more character\n  // and in addition\n  //  d) at least one line fulfills a)\n  //\n  // Since this is essentially a backwards-moving regex, it's susceptible to\n  // catstrophic backtracking and can cause the browser to hang;\n  // see e.g. http://meta.stackoverflow.com/questions/9807.\n  //\n  // Hence we replaced this by a simple state machine that just goes through the\n  // lines and checks for a), b), and c).\n\n  var match = \"\",\n    leftOver = \"\",\n    line;\n  if (chunk.before) {\n    var lines = chunk.before.replace(/\\n$/, \"\").split(\"\\n\");\n    var inChain = false;\n    for (var i = 0; i < lines.length; i++) {\n      var good = false;\n      line = lines[i];\n      inChain = inChain && line.length > 0; // c) any non-empty line continues the chain\n      if (/^>/.test(line)) { // a)\n        good = true;\n        if (!inChain && line.length > 1) // c) any line that starts with \">\" and has at least one more character starts the chain\n          inChain = true;\n      } else if (/^[ \\t]*$/.test(line)) { // b)\n        good = true;\n      } else {\n        good = inChain; // c) the line is not empty and does not start with \">\", so it matches if and only if we're in the chain\n      }\n      if (good) {\n        match += line + \"\\n\";\n      } else {\n        leftOver += match + line;\n        match = \"\\n\";\n      }\n    }\n    if (!/(^|\\n)>/.test(match)) { // d)\n      leftOver += match;\n      match = \"\";\n    }\n  }\n\n  chunk.startTag = match;\n  chunk.before = leftOver;\n\n  // end of change\n\n  if (chunk.after) {\n    chunk.after = chunk.after.replace(/^\\n?/, \"\\n\");\n  }\n\n  chunk.after = chunk.after.replace(/^(((\\n|^)(\\n[ \\t]*)*>(.+\\n)*.*)+(\\n[ \\t]*)*)/,\n    function (totalMatch) {\n      chunk.endTag = totalMatch;\n      return \"\";\n    }\n  );\n\n  var replaceBlanksInTags = function (useBracket) {\n\n    var replacement = useBracket ? \"> \" : \"\";\n\n    if (chunk.startTag) {\n      chunk.startTag = chunk.startTag.replace(/\\n((>|\\s)*)\\n$/,\n        function (totalMatch, markdown) {\n          return \"\\n\" + markdown.replace(/^[ ]{0,3}>?[ \\t]*$/gm, replacement) + \"\\n\";\n        });\n    }\n    if (chunk.endTag) {\n      chunk.endTag = chunk.endTag.replace(/^\\n((>|\\s)*)\\n/,\n        function (totalMatch, markdown) {\n          return \"\\n\" + markdown.replace(/^[ ]{0,3}>?[ \\t]*$/gm, replacement) + \"\\n\";\n        });\n    }\n  };\n\n  if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) {\n    this.wrap(chunk, SETTINGS.lineLength - 2);\n    chunk.selection = chunk.selection.replace(/^/gm, \"> \");\n    replaceBlanksInTags(true);\n    chunk.skipLines();\n  } else {\n    chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, \"\");\n    this.unwrap(chunk);\n    replaceBlanksInTags(false);\n\n    if (!/^(\\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) {\n      chunk.startTag = chunk.startTag.replace(/\\n{0,2}$/, \"\\n\\n\");\n    }\n\n    if (!/(\\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) {\n      chunk.endTag = chunk.endTag.replace(/^\\n{0,2}/, \"\\n\\n\");\n    }\n  }\n\n  chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection);\n\n  if (!/\\n/.test(chunk.selection)) {\n    chunk.selection = chunk.selection.replace(/^(> *)/,\n      function (wholeMatch, blanks) {\n        chunk.startTag += blanks;\n        return \"\";\n      });\n  }\n};\n\ncommandProto.doCode = function (chunk) {\n\n  var hasTextBefore = /\\S[ ]*$/.test(chunk.before);\n  var hasTextAfter = /^[ ]*\\S/.test(chunk.after);\n\n  // Use 'four space' markdown if the selection is on its own\n  // line or is multiline.\n  if ((!hasTextAfter && !hasTextBefore) || /\\n/.test(chunk.selection)) {\n\n    chunk.before = chunk.before.replace(/[ ]{4}$/,\n      function (totalMatch) {\n        chunk.selection = totalMatch + chunk.selection;\n        return \"\";\n      });\n\n    var nLinesBack = 1;\n    var nLinesForward = 1;\n\n    if (/(\\n|^)(\\t|[ ]{4,}).*\\n$/.test(chunk.before)) {\n      nLinesBack = 0;\n    }\n    if (/^\\n(\\t|[ ]{4,})/.test(chunk.after)) {\n      nLinesForward = 0;\n    }\n\n    chunk.skipLines(nLinesBack, nLinesForward);\n\n    if (!chunk.selection) {\n      chunk.startTag = \"    \";\n      chunk.selection = this.getString(\"codeexample\");\n    } else {\n      if (/^[ ]{0,3}\\S/m.test(chunk.selection)) {\n        if (/\\n/.test(chunk.selection))\n          chunk.selection = chunk.selection.replace(/^/gm, \"    \");\n        else // if it's not multiline, do not select the four added spaces; this is more consistent with the doList behavior\n          chunk.before += \"    \";\n      } else {\n        chunk.selection = chunk.selection.replace(/^(?:[ ]{4}|[ ]{0,3}\\t)/gm, \"\");\n      }\n    }\n  } else {\n    // Use backticks (`) to delimit the code block.\n\n    chunk.trimWhitespace();\n    chunk.findTags(/`/, /`/);\n\n    if (!chunk.startTag && !chunk.endTag) {\n      chunk.startTag = chunk.endTag = \"`\";\n      if (!chunk.selection) {\n        chunk.selection = this.getString(\"codeexample\");\n      }\n    } else if (chunk.endTag && !chunk.startTag) {\n      chunk.before += chunk.endTag;\n      chunk.endTag = \"\";\n    } else {\n      chunk.startTag = chunk.endTag = \"\";\n    }\n  }\n};\n\ncommandProto.doList = function (chunk, postProcessing, isNumberedList, isCheckList) {\n\n  // These are identical except at the very beginning and end.\n  // Should probably use the regex extension function to make this clearer.\n  var previousItemsRegex = /(\\n|^)(([ ]{0,3}([*+-]|\\d+[.])[ \\t]+.*)(\\n.+|\\n{2,}([*+-].*|\\d+[.])[ \\t]+.*|\\n{2,}[ \\t]+\\S.*)*)\\n*$/;\n  var nextItemsRegex = /^\\n*(([ ]{0,3}([*+-]|\\d+[.])[ \\t]+.*)(\\n.+|\\n{2,}([*+-].*|\\d+[.])[ \\t]+.*|\\n{2,}[ \\t]+\\S.*)*)\\n*/;\n\n  // The default bullet is a dash but others are possible.\n  // This has nothing to do with the particular HTML bullet,\n  // it's just a markdown bullet.\n  var bullet = \"-\";\n\n  // The number in a numbered list.\n  var num = 1;\n\n  // Get the item prefix - e.g. \" 1. \" for a numbered list, \" - \" for a bulleted list.\n  var getItemPrefix = function (checkListContent) {\n    var prefix;\n    if (isNumberedList) {\n      prefix = \" \" + num + \". \";\n      num++;\n    } else {\n      prefix = \" \" + bullet + \" \";\n      if (isCheckList) {\n        prefix += '[';\n        prefix += checkListContent || ' ';\n        prefix += '] ';\n      }\n    }\n    return prefix;\n  };\n\n  // Fixes the prefixes of the other list items.\n  var getPrefixedItem = function (itemText) {\n\n    // The numbering flag is unset when called by autoindent.\n    if (isNumberedList === undefined) {\n      isNumberedList = /^\\s*\\d/.test(itemText);\n    }\n\n    // Renumber/bullet the list element.\n    itemText = itemText.replace(isCheckList\n      ? /^[ ]{0,3}([*+-]|\\d+[.])\\s+\\[([ xX])\\]\\s/gm\n      : /^[ ]{0,3}([*+-]|\\d+[.])\\s/gm,\n      function (match, p1, p2) {\n        return getItemPrefix(p2);\n      });\n\n    return itemText;\n  };\n\n  chunk.findTags(/(\\n|^)*[ ]{0,3}([*+-]|\\d+[.])\\s+/, null);\n\n  if (chunk.before && !/\\n$/.test(chunk.before) && !/^\\n/.test(chunk.startTag)) {\n    chunk.before += chunk.startTag;\n    chunk.startTag = \"\";\n  }\n\n  if (chunk.startTag) {\n\n    var hasDigits = /\\d+[.]/.test(chunk.startTag);\n    chunk.startTag = \"\";\n    chunk.selection = chunk.selection.replace(/\\n[ ]{4}/g, \"\\n\");\n    this.unwrap(chunk);\n    chunk.skipLines();\n\n    if (hasDigits) {\n      // Have to renumber the bullet points if this is a numbered list.\n      chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem);\n    }\n    if (isNumberedList == hasDigits) {\n      return;\n    }\n  }\n\n  var nLinesUp = 1;\n\n  chunk.before = chunk.before.replace(previousItemsRegex,\n    function (itemText) {\n      if (/^\\s*([*+-])/.test(itemText)) {\n        bullet = re.$1;\n      }\n      nLinesUp = /[^\\n]\\n\\n[^\\n]/.test(itemText) ? 1 : 0;\n      return getPrefixedItem(itemText);\n    });\n\n  if (!chunk.selection) {\n    chunk.selection = this.getString(\"litem\");\n  }\n\n  var prefix = getItemPrefix();\n\n  var nLinesDown = 1;\n\n  chunk.after = chunk.after.replace(nextItemsRegex,\n    function (itemText) {\n      nLinesDown = /[^\\n]\\n\\n[^\\n]/.test(itemText) ? 1 : 0;\n      return getPrefixedItem(itemText);\n    });\n\n  chunk.trimWhitespace(true);\n  chunk.skipLines(nLinesUp, nLinesDown, true);\n  chunk.startTag = prefix;\n  var spaces = prefix.replace(/./g, \" \");\n  this.wrap(chunk, SETTINGS.lineLength - spaces.length);\n  chunk.selection = chunk.selection.replace(/\\n/g, \"\\n\" + spaces);\n\n};\n\ncommandProto.doTable = function (chunk) {\n  // Credit: https://github.com/fcrespo82/atom-markdown-table-formatter\n\n  var keepFirstAndLastPipes = true,\n    /*\n                      ( # header capture\n                        (?:\n                          (?:[^\\n]*?\\|[^\\n]*)       # line w/ at least one pipe\n                          \\ *                       # maybe trailing whitespace\n                        )?                          # maybe header\n                        (?:\\n|^)                    # newline\n                      )\n                      ( # format capture\n                        (?:\n                          \\|\\ *:?-+:?\\ *            # format starting w/pipe\n                          |\\|?(?:\\ *:?-+:?\\ *\\|)+   # or separated by pipe\n                        )\n                        (?:\\ *:?-+:?\\ *)?           # maybe w/o trailing pipe\n                        \\ *                         # maybe trailing whitespace\n                        \\n                          # newline\n                      )\n                      ( # body capture\n                        (?:\n                          (?:[^\\n]*?\\|[^\\n]*)       # line w/ at least one pipe\n                          \\ *                       # maybe trailing whitespace\n                          (?:\\n|$)                  # newline\n                        )+ # at least one\n                      )\n              */\n    regex = /((?:(?:[^\\n]*?\\|[^\\n]*) *)?(?:\\r?\\n|^))((?:\\| *:?-+:? *|\\|?(?: *:?-+:? *\\|)+)(?: *:?-+:? *)? *\\r?\\n)((?:(?:[^\\n]*?\\|[^\\n]*) *(?:\\r?\\n|$))+)/;\n\n\n  function padding(len, str) {\n    var result = '';\n    str = str || ' ';\n    len = Math.floor(len);\n    for (var i = 0; i < len; i++) {\n      result += str;\n    }\n    return result;\n  }\n\n  function stripTailPipes(str) {\n    return str.trim().replace(/(^\\||\\|$)/g, \"\");\n  }\n\n  function splitCells(str) {\n    return str.split('|');\n  }\n\n  function addTailPipes(str) {\n    if (keepFirstAndLastPipes) {\n      return \"|\" + str + \"|\";\n    } else {\n      return str;\n    }\n  }\n\n  function joinCells(arr) {\n    return arr.join('|');\n  }\n\n  function formatTable(text, appendNewline) {\n    var i, j, len1, ref1, ref2, ref3, k, len2, results, formatline, headerline, just, formatrow, data, line, lines, justify, cell, cells, first, last, ends, columns, content, widths, formatted, front, back;\n    formatline = text[2].trim();\n    headerline = text[1].trim();\n    ref1 = headerline.length === 0 ? [0, text[3]] : [1, text[1] + text[3]], formatrow = ref1[0], data = ref1[1];\n    lines = data.trim().split('\\n');\n    justify = [];\n    ref2 = splitCells(stripTailPipes(formatline));\n    for (j = 0, len1 = ref2.length; j < len1; j++) {\n      cell = ref2[j];\n      ref3 = cell.trim(), first = ref3[0], last = ref3[ref3.length - 1];\n      switch ((ends = (first ? first : ':') + (last ? last : ''))) {\n        case '::':\n        case '-:':\n        case ':-':\n          justify.push(ends);\n          break;\n        default:\n          justify.push('--');\n      }\n    }\n    columns = justify.length;\n    content = [];\n    for (j = 0, len1 = lines.length; j < len1; j++) {\n      line = lines[j];\n      cells = splitCells(stripTailPipes(line));\n      cells[columns - 1] = joinCells(cells.slice(columns - 1));\n      results = [];\n      for (k = 0, len2 = cells.length; k < len2; k++) {\n        cell = cells[k];\n        results.push(padding(' ') + ((ref2 = cell ? typeof cell.trim === \"function\" ? cell.trim() : void 0 : void 0) ? ref2 : '') + padding(' '));\n      }\n      content.push(results);\n    }\n    widths = [];\n    for (i = j = 0, ref2 = columns - 1; 0 <= ref2 ? j <= ref2 : j >= ref2; i = 0 <= ref2 ? ++j : --j) {\n      results = [];\n      for (k = 0, len1 = content.length; k < len1; k++) {\n        cells = content[k];\n        results.push(cells[i].length);\n      }\n      widths.push(Math.max.apply(Math, [2].concat(results)));\n    }\n    just = function (string, col) {\n      var back, front, length;\n      length = widths[col] - string.length;\n      switch (justify[col]) {\n        case '::':\n          front = padding[0], back = padding[1];\n          return padding(length / 2) + string + padding((length + 1) / 2);\n        case '-:':\n          return padding(length) + string;\n        default:\n          return string + padding(length);\n      }\n    };\n    formatted = [];\n    for (j = 0, len1 = content.length; j < len1; j++) {\n      cells = content[j];\n      results = [];\n      for (i = k = 0, ref2 = columns - 1; 0 <= ref2 ? k <= ref2 : k >= ref2; i = 0 <= ref2 ? ++k : --k) {\n        results.push(just(cells[i], i));\n      }\n      formatted.push(addTailPipes(joinCells(results)));\n    }\n    formatline = addTailPipes(joinCells((function () {\n      var j, ref2, ref3, results;\n      results = [];\n      for (i = j = 0, ref2 = columns - 1; 0 <= ref2 ? j <= ref2 : j >= ref2; i = 0 <= ref2 ? ++j : --j) {\n        ref3 = justify[i], front = ref3[0], back = ref3[1];\n        results.push(front + padding(widths[i] - 2, '-') + back);\n      }\n      return results;\n    })()));\n    formatted.splice(formatrow, 0, formatline);\n    var result = (headerline.length === 0 && text[1] !== '' ? '\\n' : '') + formatted.join('\\n');\n    if (appendNewline !== false) {\n      result += '\\n'\n    }\n    return result;\n  }\n\n  if (chunk.before.slice(-1) !== '\\n') {\n    chunk.before += '\\n';\n  }\n  var match = chunk.selection.match(regex);\n  if (match) {\n    chunk.selection = formatTable(match, chunk.selection.slice(-1) === '\\n');\n  } else {\n    var table = chunk.selection + '|\\n-|-\\n|';\n    match = table.match(regex);\n    if (!match || match[0].slice(0, table.length) !== table) {\n      return;\n    }\n    table = formatTable(match);\n    var selectionOffset = keepFirstAndLastPipes ? 1 : 0;\n    var pipePos = table.indexOf('|', selectionOffset);\n    chunk.before += table.slice(0, selectionOffset);\n    chunk.selection = table.slice(selectionOffset, pipePos);\n    chunk.after = table.slice(pipePos) + chunk.after;\n  }\n};\n\ncommandProto.doHeading = function (chunk) {\n\n  // Remove leading/trailing whitespace and reduce internal spaces to single spaces.\n  chunk.selection = chunk.selection.replace(/\\s+/g, \" \");\n  chunk.selection = chunk.selection.replace(/(^\\s+|\\s+$)/g, \"\");\n\n  // If we clicked the button with no selected text, we just\n  // make a level 2 hash header around some default text.\n  if (!chunk.selection) {\n    chunk.startTag = \"## \";\n    chunk.selection = this.getString(\"headingexample\");\n    return;\n  }\n\n  var headerLevel = 0; // The existing header level of the selected text.\n\n  // Remove any existing hash heading markdown and save the header level.\n  chunk.findTags(/#+[ ]*/, /[ ]*#+/);\n  if (/#+/.test(chunk.startTag)) {\n    headerLevel = re.lastMatch.length;\n  }\n  chunk.startTag = chunk.endTag = \"\";\n\n  // Try to get the current header level by looking for - and = in the line\n  // below the selection.\n  chunk.findTags(null, /\\s?(-+|=+)/);\n  if (/=+/.test(chunk.endTag)) {\n    headerLevel = 1;\n  }\n  if (/-+/.test(chunk.endTag)) {\n    headerLevel = 2;\n  }\n\n  // Skip to the next line so we can create the header markdown.\n  chunk.startTag = chunk.endTag = \"\";\n  chunk.skipLines(1, 1);\n\n  // We make a level 2 header if there is no current header.\n  // If there is a header level, we substract one from the header level.\n  // If it's already a level 1 header, it's removed.\n  var headerLevelToCreate = headerLevel === 0 ? 2 : headerLevel - 1;\n\n  if (headerLevelToCreate > 0) {\n\n    chunk.startTag = '';\n    while (headerLevelToCreate--) {\n      chunk.startTag += '#';\n    }\n    chunk.startTag += ' ';\n  }\n};\n\ncommandProto.doHorizontalRule = function (chunk) {\n  chunk.startTag = \"----------\\n\";\n  chunk.selection = \"\";\n  chunk.skipLines(2, 1, true);\n};\n\nexport default function (options) {\n  return new Pagedown(options);\n};\n"
  },
  {
    "path": "src/services/animationSvc.js",
    "content": "import bezierEasing from 'bezier-easing';\n\nconst easings = {\n  materialIn: bezierEasing(0.75, 0, 0.8, 0.25),\n  materialOut: bezierEasing(0.25, 0.8, 0.25, 1),\n  inOut: bezierEasing(0.25, 0.1, 0.67, 1),\n};\n\nconst vendors = ['moz', 'webkit'];\nfor (let x = 0; x < vendors.length && !window.requestAnimationFrame; x += 1) {\n  window.requestAnimationFrame = window[`${vendors[x]}RequestAnimationFrame`];\n  window.cancelAnimationFrame = window[`${vendors[x]}CancelAnimationFrame`] ||\n    window[`${vendors[x]}CancelRequestAnimationFrame`];\n}\n\nconst transformStyles = [\n  'WebkitTransform',\n  'MozTransform',\n  'msTransform',\n  'OTransform',\n  'transform',\n];\n\nconst transitionEndEvents = {\n  WebkitTransition: 'webkitTransitionEnd',\n  MozTransition: 'transitionend',\n  msTransition: 'MSTransitionEnd',\n  OTransition: 'oTransitionEnd',\n  transition: 'transitionend',\n};\n\nfunction getStyle(styles) {\n  const elt = document.createElement('div');\n  return styles.reduce((result, style) => {\n    if (elt.style[style] === undefined) {\n      return undefined;\n    }\n    return style;\n  }, undefined);\n}\n\nconst transformStyle = getStyle(transformStyles);\nconst transitionStyle = getStyle(Object.keys(transitionEndEvents));\nconst transitionEndEvent = transitionEndEvents[transitionStyle];\n\nfunction identity(x) {\n  return x;\n}\n\nfunction ElementAttribute(name) {\n  this.name = name;\n  this.setStart = (animation) => {\n    const value = animation.elt[name];\n    animation.$start[name] = value;\n    return value !== undefined && animation.$end[name] !== undefined;\n  };\n  this.applyCurrent = (animation) => {\n    animation.elt[name] = animation.$current[name];\n  };\n}\n\nfunction StyleAttribute(name, unit, defaultValue, wrap = identity) {\n  this.name = name;\n  this.setStart = (animation) => {\n    let value = parseFloat(animation.elt.style[name]);\n    if (Number.isNaN(value)) {\n      value = animation.$current[name] || defaultValue;\n    }\n    animation.$start[name] = value;\n    return animation.$end[name] !== undefined;\n  };\n  this.applyCurrent = (animation) => {\n    animation.elt.style[name] = wrap(animation.$current[name]) + unit;\n  };\n}\n\nfunction TransformAttribute(name, unit, defaultValue, wrap = identity) {\n  this.name = name;\n  this.setStart = (animation) => {\n    let value = animation.$current[name];\n    if (value === undefined) {\n      value = defaultValue;\n    }\n    animation.$start[name] = value;\n    if (animation.$end[name] === undefined) {\n      animation.$end[name] = value;\n    }\n    return value !== undefined;\n  };\n  this.applyCurrent = (animation) => {\n    const value = animation.$current[name];\n    return value !== defaultValue && `${name}(${wrap(value)}${unit})`;\n  };\n}\n\nconst attributes = [\n  new ElementAttribute('scrollTop'),\n  new ElementAttribute('scrollLeft'),\n  new StyleAttribute('opacity', '', 1),\n  new StyleAttribute('zIndex', '', 0),\n  new TransformAttribute('translateX', 'px', 0, Math.round),\n  new TransformAttribute('translateY', 'px', 0, Math.round),\n  new TransformAttribute('scale', '', 1),\n  new TransformAttribute('rotate', 'deg', 0),\n].concat([\n  'width',\n  'height',\n  'top',\n  'right',\n  'bottom',\n  'left',\n].map(name => new StyleAttribute(name, 'px', 0, Math.round)));\n\nclass Animation {\n  constructor(elt) {\n    this.elt = elt;\n    this.$current = {};\n    this.$pending = {};\n  }\n\n  start(param1, param2, param3) {\n    let endCb = param1;\n    let stepCb = param2;\n    let useTransition = false;\n    if (typeof param1 === 'boolean') {\n      useTransition = param1;\n      endCb = param2;\n      stepCb = param3;\n    }\n\n    this.stop();\n    this.$start = {};\n    this.$end = this.$pending;\n    this.$pending = {};\n    this.$attributes = attributes.filter(attribute => attribute.setStart(this));\n    this.$end.duration = this.$end.duration || 0;\n    this.$end.delay = this.$end.delay || 0;\n    this.$end.easing = easings[this.$end.easing] || easings.materialOut;\n    this.$end.endCb = typeof endCb === 'function' && endCb;\n    this.$end.stepCb = typeof stepCb === 'function' && stepCb;\n    this.$startTime = Date.now() + this.$end.delay;\n    if (!this.$end.duration) {\n      this.loop(false);\n    } else if (useTransition) {\n      this.loop(true);\n    } else {\n      this.$requestId = window.requestAnimationFrame(() => this.loop(false));\n    }\n    return this.elt;\n  }\n\n  stop() {\n    window.cancelAnimationFrame(this.$requestId);\n  }\n\n  loop(useTransition) {\n    const onTransitionEnd = (evt) => {\n      if (evt.target === this.elt) {\n        this.elt.removeEventListener(transitionEndEvent, onTransitionEnd);\n        const { endCb } = this.$end;\n        this.$end.endCb = undefined;\n        if (endCb) {\n          endCb();\n        }\n      }\n    };\n\n    let progress = (Date.now() - this.$startTime) / this.$end.duration;\n    let transition = '';\n    if (useTransition) {\n      progress = 1;\n      const transitions = [\n        'all',\n        `${this.$end.duration}ms`,\n        this.$end.easing.toCSS(),\n      ];\n      if (this.$end.delay) {\n        transitions.push(`${this.$end.duration}ms`);\n      }\n      transition = transitions.join(' ');\n      if (this.$end.endCb) {\n        this.elt.addEventListener(transitionEndEvent, onTransitionEnd);\n      }\n    } else if (progress < 1) {\n      this.$requestId = window.requestAnimationFrame(() => this.loop(false));\n      if (progress < 0) {\n        return;\n      }\n    } else if (this.$end.endCb) {\n      this.$requestId = window.requestAnimationFrame(this.$end.endCb);\n    }\n\n    const coeff = this.$end.easing.get(progress);\n    const transforms = this.$attributes.reduce((result, attribute) => {\n      if (progress < 1) {\n        const diff = this.$end[attribute.name] - this.$start[attribute.name];\n        this.$current[attribute.name] = this.$start[attribute.name] + (diff * coeff);\n      } else {\n        this.$current[attribute.name] = this.$end[attribute.name];\n      }\n      const transform = attribute.applyCurrent(this);\n      if (transform) {\n        result.push(transform);\n      }\n      return result;\n    }, []);\n\n    if (transforms.length) {\n      transforms.push('translateZ(0)'); // activate GPU\n    }\n    const transform = transforms.join(' ');\n    this.elt.style[transformStyle] = transform;\n    this.elt.style[transitionStyle] = transition;\n    if (this.$end.stepCb) {\n      this.$end.stepCb();\n    }\n  }\n}\n\nattributes.map(attribute => attribute.name).concat('duration', 'easing', 'delay')\n  .forEach((name) => {\n    Animation.prototype[name] = function setter(val) {\n      this.$pending[name] = val;\n      return this;\n    };\n  });\n\nfunction animate(elt) {\n  if (!elt.$animation) {\n    elt.$animation = new Animation(elt);\n  }\n  return elt.$animation;\n}\n\nexport default {\n  animate,\n};\n"
  },
  {
    "path": "src/services/backupSvc.js",
    "content": "import workspaceSvc from './workspaceSvc';\nimport utils from './utils';\n\nexport default {\n  async importBackup(jsonValue) {\n    const fileNameMap = {};\n    const folderNameMap = {};\n    const parentIdMap = {};\n    const textMap = {};\n    const propertiesMap = {};\n    const discussionsMap = {};\n    const commentsMap = {};\n    const folderIdMap = {\n      trash: 'trash',\n    };\n\n    // Parse JSON value\n    const parsedValue = JSON.parse(jsonValue);\n    Object.entries(parsedValue).forEach(([id, value]) => {\n      if (value) {\n        const v4Match = id.match(/^file\\.([^.]+)\\.([^.]+)$/);\n        if (v4Match) {\n          // StackEdit v4 format\n          const [, v4Id, type] = v4Match;\n          if (type === 'title') {\n            fileNameMap[v4Id] = value;\n          } else if (type === 'content') {\n            textMap[v4Id] = value;\n          }\n        } else if (value.type === 'folder') {\n          // StackEdit v5 folder\n          folderIdMap[id] = utils.uid();\n          folderNameMap[id] = value.name;\n          parentIdMap[id] = `${value.parentId || ''}`;\n        } else if (value.type === 'file') {\n          // StackEdit v5 file\n          fileNameMap[id] = value.name;\n          parentIdMap[id] = `${value.parentId || ''}`;\n        } else if (value.type === 'content') {\n          // StackEdit v5 content\n          const [fileId] = id.split('/');\n          if (fileId) {\n            textMap[fileId] = value.text;\n            propertiesMap[fileId] = value.properties;\n            discussionsMap[fileId] = value.discussions;\n            commentsMap[fileId] = value.comments;\n          }\n        }\n      }\n    });\n\n    await utils.awaitSequence(\n      Object.keys(folderNameMap),\n      async externalId => workspaceSvc.setOrPatchItem({\n        id: folderIdMap[externalId],\n        type: 'folder',\n        name: folderNameMap[externalId],\n        parentId: folderIdMap[parentIdMap[externalId]],\n      }),\n    );\n\n    await utils.awaitSequence(\n      Object.keys(fileNameMap),\n      async externalId => workspaceSvc.createFile({\n        name: fileNameMap[externalId],\n        parentId: folderIdMap[parentIdMap[externalId]],\n        text: textMap[externalId],\n        properties: propertiesMap[externalId],\n        discussions: discussionsMap[externalId],\n        comments: commentsMap[externalId],\n      }, true),\n    );\n  },\n};\n"
  },
  {
    "path": "src/services/badgeSvc.js",
    "content": "import store from '../store';\n\nlet lastEarnedFeatureIds = null;\nlet debounceTimeoutId;\n\nconst showInfo = () => {\n  const earnedBadges = store.getters['data/allBadges']\n    .filter(badge => badge.isEarned && !lastEarnedFeatureIds.has(badge.featureId));\n  if (earnedBadges.length) {\n    store.dispatch('notification/badge', earnedBadges.length > 1\n      ? `You've earned ${earnedBadges.length} badges: ${earnedBadges.map(badge => `\"${badge.name}\"`).join(', ')}.`\n      : `You've earned 1 badge: \"${earnedBadges[0].name}\".`);\n  }\n  lastEarnedFeatureIds = null;\n};\n\nexport default {\n  addBadge(featureId) {\n    if (!store.getters['data/badgeCreations'][featureId]) {\n      if (!lastEarnedFeatureIds) {\n        const earnedFeatureIds = store.getters['data/allBadges']\n          .filter(badge => badge.isEarned)\n          .map(badge => badge.featureId);\n        lastEarnedFeatureIds = new Set(earnedFeatureIds);\n      }\n\n      store.dispatch('data/patchBadgeCreations', {\n        [featureId]: {\n          created: Date.now(),\n        },\n      });\n\n      clearTimeout(debounceTimeoutId);\n      debounceTimeoutId = setTimeout(() => showInfo(), 5000);\n    }\n  },\n};\n"
  },
  {
    "path": "src/services/diffUtils.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport utils from './utils';\n\nconst diffMatchPatch = new DiffMatchPatch();\ndiffMatchPatch.Match_Distance = 10000;\n\nfunction makePatchableText(content, markerKeys, markerIdxMap) {\n  if (!content || !content.discussions) {\n    return null;\n  }\n  const markers = [];\n  // Sort keys to have predictable marker positions in case of same offset\n  const discussionKeys = Object.keys(content.discussions).sort();\n  discussionKeys.forEach((discussionId) => {\n    const discussion = content.discussions[discussionId];\n\n    function addMarker(offsetName) {\n      const markerKey = discussionId + offsetName;\n      if (discussion[offsetName] !== undefined) {\n        let idx = markerIdxMap[markerKey];\n        if (idx === undefined) {\n          idx = markerKeys.length;\n          markerIdxMap[markerKey] = idx;\n          markerKeys.push({\n            id: discussionId,\n            offsetName,\n          });\n        }\n        markers.push({\n          idx,\n          offset: discussion[offsetName],\n        });\n      }\n    }\n\n    addMarker('start');\n    addMarker('end');\n  });\n\n  let lastOffset = 0;\n  let result = '';\n  markers\n    .sort((marker1, marker2) => marker1.offset - marker2.offset)\n    .forEach((marker) => {\n      result +=\n        content.text.slice(lastOffset, marker.offset) +\n        String.fromCharCode(0xe000 + marker.idx); // Use a character from the private use area\n      lastOffset = marker.offset;\n    });\n  return result + content.text.slice(lastOffset);\n}\n\nfunction stripDiscussionOffsets(objectMap) {\n  if (objectMap == null) {\n    return objectMap;\n  }\n  const result = {};\n  Object.keys(objectMap).forEach((id) => {\n    result[id] = {\n      text: objectMap[id].text,\n    };\n  });\n  return result;\n}\n\nfunction restoreDiscussionOffsets(content, markerKeys) {\n  if (markerKeys.length) {\n    // Go through markers\n    let count = 0;\n    content.text = content.text.replace(\n      new RegExp(`[\\ue000-${String.fromCharCode((0xe000 + markerKeys.length) - 1)}]`, 'g'),\n      (match, offset) => {\n        const idx = match.charCodeAt(0) - 0xe000;\n        const markerKey = markerKeys[idx];\n        const discussion = content.discussions[markerKey.id];\n        if (discussion) {\n          discussion[markerKey.offsetName] = offset - count;\n        }\n        count += 1;\n        return '';\n      },\n    );\n    // Sanitize offsets\n    Object.keys(content.discussions).forEach((discussionId) => {\n      const discussion = content.discussions[discussionId];\n      if (discussion.start === undefined) {\n        discussion.start = discussion.end || 0;\n      }\n      if (discussion.end === undefined || discussion.end < discussion.start) {\n        discussion.end = discussion.start;\n      }\n    });\n  }\n}\n\nfunction mergeText(serverText, clientText, lastMergedText) {\n  const serverClientDiffs = diffMatchPatch.diff_main(serverText, clientText);\n  diffMatchPatch.diff_cleanupSemantic(serverClientDiffs);\n  // Fusion text is a mix of both server and client contents\n  const fusionText = serverClientDiffs.map(diff => diff[1]).join('');\n  if (!lastMergedText) {\n    return fusionText;\n  }\n  // Let's try to find out what text has to be removed from fusion\n  const intersectionText = serverClientDiffs\n    // Keep only equalities\n    .filter(diff => diff[0] === DiffMatchPatch.DIFF_EQUAL)\n    .map(diff => diff[1]).join('');\n  const lastMergedTextDiffs = diffMatchPatch.diff_main(lastMergedText, intersectionText)\n    // Keep only equalities and deletions\n    .filter(diff => diff[0] !== DiffMatchPatch.DIFF_INSERT);\n  diffMatchPatch.diff_cleanupSemantic(lastMergedTextDiffs);\n  // Make a patch with deletions only\n  const patches = diffMatchPatch.patch_make(lastMergedText, lastMergedTextDiffs);\n  // Apply patch to fusion text\n  return diffMatchPatch.patch_apply(patches, fusionText)[0];\n}\n\nfunction mergeValues(serverValue, clientValue, lastMergedValue) {\n  if (!lastMergedValue) {\n    return serverValue || clientValue; // Take the server value in priority\n  }\n  const newSerializedValue = utils.serializeObject(clientValue);\n  const serverSerializedValue = utils.serializeObject(serverValue);\n  if (newSerializedValue === serverSerializedValue) {\n    return serverValue; // no conflict\n  }\n  const oldSerializedValue = utils.serializeObject(lastMergedValue);\n  if (oldSerializedValue !== newSerializedValue && !serverValue) {\n    return clientValue; // Removed on server but changed on client\n  }\n  if (oldSerializedValue !== serverSerializedValue && !clientValue) {\n    return serverValue; // Removed on client but changed on server\n  }\n  if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) {\n    return clientValue; // Take the client value\n  }\n  return serverValue; // Take the server value\n}\n\nfunction mergeObjects(serverObject, clientObject, lastMergedObject = {}) {\n  const mergedObject = {};\n  Object.keys({\n    ...clientObject,\n    ...serverObject,\n  }).forEach((key) => {\n    const mergedValue = mergeValues(serverObject[key], clientObject[key], lastMergedObject[key]);\n    if (mergedValue != null) {\n      mergedObject[key] = mergedValue;\n    }\n  });\n  return utils.deepCopy(mergedObject);\n}\n\nfunction mergeContent(serverContent, clientContent, lastMergedContent = {}) {\n  const markerKeys = [];\n  const markerIdxMap = Object.create(null);\n  const lastMergedText = makePatchableText(lastMergedContent, markerKeys, markerIdxMap);\n  const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap);\n  const clientText = makePatchableText(clientContent, markerKeys, markerIdxMap);\n  const isServerTextChanges = lastMergedText !== serverText;\n  const isClientTextChanges = lastMergedText !== clientText;\n  const isTextSynchronized = serverText === clientText;\n  let text = clientText;\n  if (!isTextSynchronized && isServerTextChanges) {\n    text = serverText;\n    if (isClientTextChanges) {\n      text = mergeText(serverText, clientText, lastMergedText);\n    }\n  }\n\n  const result = {\n    text,\n    properties: mergeValues(\n      serverContent.properties,\n      clientContent.properties,\n      lastMergedContent.properties,\n    ),\n    discussions: mergeObjects(\n      stripDiscussionOffsets(serverContent.discussions),\n      stripDiscussionOffsets(clientContent.discussions),\n      stripDiscussionOffsets(lastMergedContent.discussions),\n    ),\n    comments: mergeObjects(\n      serverContent.comments,\n      clientContent.comments,\n      lastMergedContent.comments,\n    ),\n  };\n  restoreDiscussionOffsets(result, markerKeys);\n  return result;\n}\n\nexport default {\n  makePatchableText,\n  restoreDiscussionOffsets,\n  mergeObjects,\n  mergeContent,\n};\n"
  },
  {
    "path": "src/services/editor/cledit/cleditCore.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport TurndownService from 'turndown/lib/turndown.browser.umd';\nimport htmlSanitizer from '../../../libs/htmlSanitizer';\nimport store from '../../../store';\n\nfunction cledit(contentElt, scrollEltOpt, isMarkdown = false) {\n  const scrollElt = scrollEltOpt || contentElt;\n  const editor = {\n    $contentElt: contentElt,\n    $scrollElt: scrollElt,\n    $keystrokes: [],\n    $markers: {},\n  };\n  cledit.Utils.createEventHooks(editor);\n  const { debounce } = cledit.Utils;\n\n  contentElt.setAttribute('tabindex', '0'); // To have focus even when disabled\n  editor.toggleEditable = (isEditable) => {\n    contentElt.contentEditable = isEditable == null ? !contentElt.contentEditable : isEditable;\n  };\n  editor.toggleEditable(true);\n\n  function getTextContent() {\n    // Markdown-it sanitization (Mac/DOS to Unix)\n    let textContent = contentElt.textContent.replace(/\\r[\\n\\u0085]?|[\\u2424\\u2028\\u0085]/g, '\\n');\n    if (textContent.slice(-1) !== '\\n') {\n      textContent += '\\n';\n    }\n    return textContent;\n  }\n\n  let lastTextContent = getTextContent();\n  const highlighter = new cledit.Highlighter(editor);\n\n  /* eslint-disable new-cap */\n  const diffMatchPatch = new DiffMatchPatch();\n  /* eslint-enable new-cap */\n  const selectionMgr = new cledit.SelectionMgr(editor);\n\n  function adjustCursorPosition(force) {\n    selectionMgr.saveSelectionState(true, true, force);\n  }\n\n  function replaceContent(selectionStart, selectionEnd, replacement) {\n    const min = Math.min(selectionStart, selectionEnd);\n    const max = Math.max(selectionStart, selectionEnd);\n    const range = selectionMgr.createRange(min, max);\n    const rangeText = `${range}`;\n    // Range can contain a br element, which is not taken into account in rangeText\n    if (rangeText.length === max - min && rangeText === replacement) {\n      return null;\n    }\n    range.deleteContents();\n    range.insertNode(document.createTextNode(replacement));\n    return range;\n  }\n\n  let ignoreUndo = false;\n  let noContentFix = false;\n\n  function setContent(value, noUndo, maxStartOffsetOpt) {\n    const textContent = getTextContent();\n    const maxStartOffset = maxStartOffsetOpt != null && maxStartOffsetOpt < textContent.length\n      ? maxStartOffsetOpt\n      : textContent.length - 1;\n    const startOffset = Math.min(\n      diffMatchPatch.diff_commonPrefix(textContent, value),\n      maxStartOffset,\n    );\n    const endOffset = Math.min(\n      diffMatchPatch.diff_commonSuffix(textContent, value),\n      textContent.length - startOffset,\n      value.length - startOffset,\n    );\n    const replacement = value.substring(startOffset, value.length - endOffset);\n    const range = replaceContent(startOffset, textContent.length - endOffset, replacement);\n    if (range) {\n      ignoreUndo = noUndo;\n      noContentFix = true;\n    }\n    return {\n      start: startOffset,\n      end: value.length - endOffset,\n      range,\n    };\n  }\n\n  const undoMgr = new cledit.UndoMgr(editor);\n\n  function replace(selectionStart, selectionEnd, replacement) {\n    undoMgr.setDefaultMode('single');\n    replaceContent(selectionStart, selectionEnd, replacement);\n    const startOffset = Math.min(selectionStart, selectionEnd);\n    const endOffset = startOffset + replacement.length;\n    selectionMgr.setSelectionStartEnd(endOffset, endOffset);\n    selectionMgr.updateCursorCoordinates(true);\n  }\n\n  function replaceAll(search, replacement, startOffset = 0) {\n    undoMgr.setDefaultMode('single');\n    const text = getTextContent();\n    const subtext = getTextContent().slice(startOffset);\n    const value = subtext.replace(search, replacement);\n    if (value !== subtext) {\n      const offset = editor.setContent(text.slice(0, startOffset) + value);\n      selectionMgr.setSelectionStartEnd(offset.end, offset.end);\n      selectionMgr.updateCursorCoordinates(true);\n    }\n  }\n\n  function focus() {\n    selectionMgr.restoreSelection();\n    contentElt.focus();\n  }\n\n  function addMarker(marker) {\n    editor.$markers[marker.id] = marker;\n  }\n\n  function removeMarker(marker) {\n    delete editor.$markers[marker.id];\n  }\n\n  const triggerSpellCheck = debounce(() => {\n    // Hack for Chrome to trigger the spell checker\n    const selection = window.getSelection();\n    if (selectionMgr.hasFocus()\n      && !highlighter.isComposing\n      && selectionMgr.selectionStart === selectionMgr.selectionEnd\n      && selection.modify\n    ) {\n      if (selectionMgr.selectionStart) {\n        selection.modify('move', 'backward', 'character');\n        selection.modify('move', 'forward', 'character');\n      } else {\n        selection.modify('move', 'forward', 'character');\n        selection.modify('move', 'backward', 'character');\n      }\n    }\n  }, 10);\n\n  let watcher;\n  let skipSaveSelection;\n  function checkContentChange(mutations) {\n    watcher.noWatch(() => {\n      const removedSections = [];\n      const modifiedSections = [];\n\n      function markModifiedSection(node) {\n        let currentNode = node;\n        while (currentNode && currentNode !== contentElt) {\n          if (currentNode.section) {\n            const array = currentNode.parentNode ? modifiedSections : removedSections;\n            if (array.indexOf(currentNode.section) === -1) {\n              array.push(currentNode.section);\n            }\n            return;\n          }\n          currentNode = currentNode.parentNode;\n        }\n      }\n\n      mutations.cl_each((mutation) => {\n        markModifiedSection(mutation.target);\n        mutation.addedNodes.cl_each(markModifiedSection);\n        mutation.removedNodes.cl_each(markModifiedSection);\n      });\n      highlighter.fixContent(modifiedSections, removedSections, noContentFix);\n      noContentFix = false;\n    });\n\n    if (!skipSaveSelection) {\n      selectionMgr.saveSelectionState();\n    }\n    skipSaveSelection = false;\n\n    const newTextContent = getTextContent();\n    const diffs = diffMatchPatch.diff_main(lastTextContent, newTextContent);\n    editor.$markers.cl_each((marker) => {\n      marker.adjustOffset(diffs);\n    });\n\n    const sectionList = highlighter.parseSections(newTextContent);\n    editor.$trigger('contentChanged', newTextContent, diffs, sectionList);\n    if (!ignoreUndo) {\n      undoMgr.addDiffs(lastTextContent, newTextContent, diffs);\n      undoMgr.setDefaultMode('typing');\n      undoMgr.saveState();\n    }\n    ignoreUndo = false;\n    lastTextContent = newTextContent;\n    triggerSpellCheck();\n  }\n\n  // Detect editor changes\n  watcher = new cledit.Watcher(editor, checkContentChange);\n  watcher.startWatching();\n\n  function setSelection(start, end) {\n    selectionMgr.setSelectionStartEnd(start, end == null ? start : end);\n    selectionMgr.updateCursorCoordinates();\n  }\n\n  function keydownHandler(handler) {\n    return (evt) => {\n      if (\n        evt.which !== 17 && // Ctrl\n        evt.which !== 91 && // Cmd\n        evt.which !== 18 && // Alt\n        evt.which !== 16 // Shift\n      ) {\n        handler(evt);\n      }\n    };\n  }\n\n  let windowKeydownListener;\n  let windowMouseListener;\n  let windowResizeListener;\n  function tryDestroy() {\n    if (document.contains(contentElt)) {\n      return false;\n    }\n    watcher.stopWatching();\n    window.removeEventListener('keydown', windowKeydownListener);\n    window.removeEventListener('mousedown', windowMouseListener);\n    window.removeEventListener('mouseup', windowMouseListener);\n    window.removeEventListener('resize', windowResizeListener);\n    editor.$trigger('destroy');\n    return true;\n  }\n\n  // In case of Ctrl/Cmd+A outside the editor element\n  windowKeydownListener = (evt) => {\n    if (!tryDestroy()) {\n      keydownHandler(() => {\n        adjustCursorPosition();\n      })(evt);\n    }\n  };\n  window.addEventListener('keydown', windowKeydownListener);\n\n  // Mouseup can happen outside the editor element\n  windowMouseListener = () => {\n    if (!tryDestroy()) {\n      selectionMgr.saveSelectionState(true, false);\n    }\n  };\n  window.addEventListener('mousedown', windowMouseListener);\n  window.addEventListener('mouseup', windowMouseListener);\n\n  // Resize provokes cursor coordinate changes\n  windowResizeListener = () => {\n    if (!tryDestroy()) {\n      selectionMgr.updateCursorCoordinates();\n    }\n  };\n  window.addEventListener('resize', windowResizeListener);\n\n  // Provokes selection changes and does not fire mouseup event on Chrome/OSX\n  contentElt.addEventListener(\n    'contextmenu',\n    selectionMgr.saveSelectionState.cl_bind(selectionMgr, true, false),\n  );\n\n  contentElt.addEventListener('keydown', keydownHandler((evt) => {\n    selectionMgr.saveSelectionState();\n\n    // Perform keystroke\n    let contentChanging = false;\n    const textContent = getTextContent();\n    let min = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd);\n    let max = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd);\n    const state = {\n      before: textContent.slice(0, min),\n      after: textContent.slice(max),\n      selection: textContent.slice(min, max),\n      isBackwardSelection: selectionMgr.selectionStart > selectionMgr.selectionEnd,\n    };\n    editor.$keystrokes.cl_some((keystroke) => {\n      if (!keystroke.handler(evt, state, editor)) {\n        return false;\n      }\n      const newContent = state.before + state.selection + state.after;\n      if (newContent !== getTextContent()) {\n        editor.setContent(newContent, false, min);\n        contentChanging = true;\n        skipSaveSelection = true;\n        highlighter.cancelComposition = true;\n      }\n      min = state.before.length;\n      max = min + state.selection.length;\n      selectionMgr.setSelectionStartEnd(\n        state.isBackwardSelection ? max : min,\n        state.isBackwardSelection ? min : max,\n        !contentChanging, // Expect a restore selection on mutation event\n      );\n      return true;\n    });\n\n    if (!contentChanging) {\n      // Optimization to avoid saving selection\n      adjustCursorPosition();\n    }\n  }));\n\n  contentElt.addEventListener('compositionstart', () => {\n    highlighter.isComposing += 1;\n  });\n\n  contentElt.addEventListener('compositionend', () => {\n    setTimeout(() => {\n      if (highlighter.isComposing) {\n        highlighter.isComposing -= 1;\n        if (!highlighter.isComposing) {\n          checkContentChange([]);\n        }\n      }\n    }, 1);\n  });\n\n  let turndownService;\n  if (isMarkdown) {\n    contentElt.addEventListener('copy', (evt) => {\n      if (evt.clipboardData) {\n        evt.clipboardData.setData('text/plain', selectionMgr.getSelectedText());\n        evt.preventDefault();\n      }\n    });\n\n    contentElt.addEventListener('cut', (evt) => {\n      if (evt.clipboardData) {\n        evt.clipboardData.setData('text/plain', selectionMgr.getSelectedText());\n        evt.preventDefault();\n        replace(selectionMgr.selectionStart, selectionMgr.selectionEnd, '');\n      } else {\n        undoMgr.setCurrentMode('single');\n      }\n      adjustCursorPosition();\n    });\n\n    turndownService = new TurndownService(store.getters['data/computedSettings'].turndown);\n    turndownService.escape = str => str; // Disable escaping\n  }\n\n  contentElt.addEventListener('paste', (evt) => {\n    undoMgr.setCurrentMode('single');\n    evt.preventDefault();\n    let data;\n    let { clipboardData } = evt;\n    if (clipboardData) {\n      data = clipboardData.getData('text/plain');\n      if (turndownService) {\n        try {\n          const html = clipboardData.getData('text/html');\n          if (html) {\n            const sanitizedHtml = htmlSanitizer.sanitizeHtml(html)\n              .replace(/&#160;/g, ' '); // Replace non-breaking spaces with classic spaces\n            if (sanitizedHtml) {\n              data = turndownService.turndown(sanitizedHtml);\n            }\n          }\n        } catch (e) {\n          // Ignore\n        }\n      }\n    } else {\n      ({ clipboardData } = window.clipboardData);\n      data = clipboardData && clipboardData.getData('Text');\n    }\n    if (!data) {\n      return;\n    }\n    replace(selectionMgr.selectionStart, selectionMgr.selectionEnd, data);\n    adjustCursorPosition();\n  });\n\n  contentElt.addEventListener('focus', () => {\n    editor.$trigger('focus');\n  });\n\n  contentElt.addEventListener('blur', () => {\n    editor.$trigger('blur');\n  });\n\n  function addKeystroke(keystroke) {\n    const keystrokes = Array.isArray(keystroke) ? keystroke : [keystroke];\n    editor.$keystrokes = editor.$keystrokes\n      .concat(keystrokes)\n      .sort((keystroke1, keystroke2) => keystroke1.priority - keystroke2.priority);\n  }\n  addKeystroke(cledit.defaultKeystrokes);\n\n  editor.selectionMgr = selectionMgr;\n  editor.undoMgr = undoMgr;\n  editor.highlighter = highlighter;\n  editor.watcher = watcher;\n  editor.adjustCursorPosition = adjustCursorPosition;\n  editor.setContent = setContent;\n  editor.replace = replace;\n  editor.replaceAll = replaceAll;\n  editor.getContent = getTextContent;\n  editor.focus = focus;\n  editor.setSelection = setSelection;\n  editor.addKeystroke = addKeystroke;\n  editor.addMarker = addMarker;\n  editor.removeMarker = removeMarker;\n\n  editor.init = (opts = {}) => {\n    const options = ({\n      getCursorFocusRatio() {\n        return 0.1;\n      },\n      sectionHighlighter(section) {\n        return section.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\\u00a0/g, ' ');\n      },\n      sectionDelimiter: '',\n    }).cl_extend(opts);\n    editor.options = options;\n\n    if (options.content !== undefined) {\n      lastTextContent = options.content.toString();\n      if (lastTextContent.slice(-1) !== '\\n') {\n        lastTextContent += '\\n';\n      }\n    }\n\n    const sectionList = highlighter.parseSections(lastTextContent, true);\n    editor.$trigger('contentChanged', lastTextContent, [0, lastTextContent], sectionList);\n    if (options.selectionStart !== undefined && options.selectionEnd !== undefined) {\n      editor.setSelection(options.selectionStart, options.selectionEnd);\n    } else {\n      selectionMgr.saveSelectionState();\n    }\n    undoMgr.init(options);\n\n    if (options.scrollTop !== undefined) {\n      scrollElt.scrollTop = options.scrollTop;\n    }\n  };\n\n  return editor;\n}\n\nexport default cledit;\n"
  },
  {
    "path": "src/services/editor/cledit/cleditHighlighter.js",
    "content": "import cledit from './cleditCore';\n\nconst styleElts = [];\n\nfunction createStyleSheet(document) {\n  const styleElt = document.createElement('style');\n  styleElt.type = 'text/css';\n  styleElt.innerHTML = '.cledit-section * { display: inline; }';\n  document.head.appendChild(styleElt);\n  styleElts.push(styleElt);\n}\n\nfunction Highlighter(editor) {\n  cledit.Utils.createEventHooks(this);\n\n  if (!styleElts.cl_some(styleElt => document.head.contains(styleElt))) {\n    createStyleSheet(document);\n  }\n\n  const contentElt = editor.$contentElt;\n  this.isComposing = 0;\n\n  let sectionList = [];\n  let insertBeforeSection;\n  const useBr = cledit.Utils.isWebkit;\n  const trailingNodeTag = 'div';\n  const hiddenLfInnerHtml = '<br><span class=\"hd-lf\" style=\"display: none\">\\n</span>';\n\n  const lfHtml = `<span class=\"lf\">${useBr ? hiddenLfInnerHtml : '\\n'}</span>`;\n\n  this.fixContent = (modifiedSections, removedSections, noContentFix) => {\n    modifiedSections.cl_each((section) => {\n      section.forceHighlighting = true;\n      if (!noContentFix) {\n        if (useBr) {\n          section.elt.getElementsByClassName('hd-lf')\n            .cl_each(lfElt => lfElt.parentNode.removeChild(lfElt));\n          section.elt.getElementsByTagName('br')\n            .cl_each(brElt => brElt.parentNode.replaceChild(document.createTextNode('\\n'), brElt));\n        }\n        if (section.elt.textContent.slice(-1) !== '\\n') {\n          section.elt.appendChild(document.createTextNode('\\n'));\n        }\n      }\n    });\n  };\n\n  this.addTrailingNode = () => {\n    this.trailingNode = document.createElement(trailingNodeTag);\n    contentElt.appendChild(this.trailingNode);\n  };\n\n  class Section {\n    constructor(text) {\n      this.text = text.text === undefined ? text : text.text;\n      this.data = text.data;\n    }\n    setElement(elt) {\n      this.elt = elt;\n      elt.section = this;\n    }\n  }\n\n  this.parseSections = (content, isInit) => {\n    if (this.isComposing && !this.cancelComposition) {\n      return sectionList;\n    }\n\n    this.cancelComposition = false;\n    const newSectionList = (editor.options.sectionParser\n      ? editor.options.sectionParser(content)\n      : [content])\n      .cl_map(sectionText => new Section(sectionText));\n\n    let modifiedSections = [];\n    let sectionsToRemove = [];\n    insertBeforeSection = undefined;\n\n    if (isInit) {\n      // Render everything if isInit\n      sectionsToRemove = sectionList;\n      sectionList = newSectionList;\n      modifiedSections = newSectionList;\n    } else {\n      // Find modified section starting from top\n      let leftIndex = sectionList.length;\n      sectionList.cl_some((section, index) => {\n        const newSection = newSectionList[index];\n        if (index >= newSectionList.length ||\n          section.forceHighlighting ||\n          // Check text modification\n          section.text !== newSection.text ||\n          // Check that section has not been detached or moved\n          section.elt.parentNode !== contentElt ||\n          // Check also the content since nodes can be injected in sections via copy/paste\n          section.elt.textContent !== newSection.text\n        ) {\n          leftIndex = index;\n          return true;\n        }\n        return false;\n      });\n\n      // Find modified section starting from bottom\n      let rightIndex = -sectionList.length;\n      sectionList.slice().reverse().cl_some((section, index) => {\n        const newSection = newSectionList[newSectionList.length - index - 1];\n        if (index >= newSectionList.length ||\n          section.forceHighlighting ||\n          // Check modified\n          section.text !== newSection.text ||\n          // Check that section has not been detached or moved\n          section.elt.parentNode !== contentElt ||\n          // Check also the content since nodes can be injected in sections via copy/paste\n          section.elt.textContent !== newSection.text\n        ) {\n          rightIndex = -index;\n          return true;\n        }\n        return false;\n      });\n\n      if (leftIndex - rightIndex > sectionList.length) {\n        // Prevent overlap\n        rightIndex = leftIndex - sectionList.length;\n      }\n\n      const leftSections = sectionList.slice(0, leftIndex);\n      modifiedSections = newSectionList.slice(leftIndex, newSectionList.length + rightIndex);\n      const rightSections = sectionList.slice(sectionList.length + rightIndex, sectionList.length);\n      [insertBeforeSection] = rightSections;\n      sectionsToRemove = sectionList.slice(leftIndex, sectionList.length + rightIndex);\n      sectionList = leftSections.concat(modifiedSections).concat(rightSections);\n    }\n\n    const highlight = (section) => {\n      const html = editor.options.sectionHighlighter(section).replace(/\\n/g, lfHtml);\n      const sectionElt = document.createElement('div');\n      sectionElt.className = 'cledit-section';\n      sectionElt.innerHTML = html;\n      section.setElement(sectionElt);\n      this.$trigger('sectionHighlighted', section);\n    };\n\n    const newSectionEltList = document.createDocumentFragment();\n    modifiedSections.cl_each((section) => {\n      section.forceHighlighting = false;\n      highlight(section);\n      newSectionEltList.appendChild(section.elt);\n    });\n    editor.watcher.noWatch(() => {\n      if (isInit) {\n        contentElt.innerHTML = '';\n        contentElt.appendChild(newSectionEltList);\n        this.addTrailingNode();\n        return;\n      }\n\n      // Remove outdated sections\n      sectionsToRemove.cl_each((section) => {\n        // section may be already removed\n        if (section.elt.parentNode === contentElt) {\n          contentElt.removeChild(section.elt);\n        }\n        // To detect sections that come back with built-in undo\n        section.elt.section = undefined;\n      });\n\n      if (insertBeforeSection !== undefined) {\n        contentElt.insertBefore(newSectionEltList, insertBeforeSection.elt);\n      } else {\n        contentElt.appendChild(newSectionEltList);\n      }\n\n      // Remove unauthorized nodes (text nodes outside of sections or\n      // duplicated sections via copy/paste)\n      let childNode = contentElt.firstChild;\n      while (childNode) {\n        const nextNode = childNode.nextSibling;\n        if (!childNode.section) {\n          contentElt.removeChild(childNode);\n        }\n        childNode = nextNode;\n      }\n      this.addTrailingNode();\n      this.$trigger('highlighted');\n\n      if (editor.selectionMgr.hasFocus()) {\n        editor.selectionMgr.restoreSelection();\n        editor.selectionMgr.updateCursorCoordinates();\n      }\n    });\n\n    return sectionList;\n  };\n}\n\ncledit.Highlighter = Highlighter;\n\n"
  },
  {
    "path": "src/services/editor/cledit/cleditKeystroke.js",
    "content": "import cledit from './cleditCore';\n\nfunction Keystroke(handler, priority) {\n  this.handler = handler;\n  this.priority = priority || 100;\n}\n\ncledit.Keystroke = Keystroke;\n\nlet clearNewline;\nconst charTypes = Object.create(null);\n\n// Word separators, as in Sublime Text\n'./\\\\()\"\\'-:,.;<>~!@#$%^&*|+=[]{}`~?'.split('').cl_each((wordSeparator) => {\n  charTypes[wordSeparator] = 'wordSeparator';\n});\ncharTypes[' '] = 'space';\ncharTypes['\\t'] = 'space';\ncharTypes['\\n'] = 'newLine';\n\nfunction getNextWordOffset(text, offset, isBackward) {\n  let previousType;\n  let result = offset;\n  while ((isBackward && result > 0) || (!isBackward && result < text.length)) {\n    const currentType = charTypes[isBackward ? text[result - 1] : text[result]] || 'word';\n    if (previousType && currentType !== previousType) {\n      if (previousType === 'word' || currentType === 'space' || previousType === 'newLine' || currentType === 'newLine') {\n        break;\n      }\n    }\n    previousType = currentType;\n    if (isBackward) {\n      result -= 1;\n    } else {\n      result += 1;\n    }\n  }\n  return result;\n}\n\ncledit.defaultKeystrokes = [\n\n  new Keystroke((evt, state, editor) => {\n    if ((!evt.ctrlKey && !evt.metaKey) || evt.altKey) {\n      return false;\n    }\n    const keyCode = evt.charCode || evt.keyCode;\n    const keyCodeChar = String.fromCharCode(keyCode).toLowerCase();\n    let action;\n    switch (keyCodeChar) {\n      case 'y':\n        action = 'redo';\n        break;\n      case 'z':\n        action = evt.shiftKey ? 'redo' : 'undo';\n        break;\n      default:\n    }\n    if (action) {\n      evt.preventDefault();\n      setTimeout(() => editor.undoMgr[action](), 10);\n      return true;\n    }\n    return false;\n  }),\n\n  new Keystroke((evt, state) => {\n    if (evt.which !== 9 /* tab */ || evt.metaKey || evt.ctrlKey) {\n      return false;\n    }\n\n    const strSplice = (str, i, remove, add = '') =>\n      str.slice(0, i) + add + str.slice(i + (+remove || 0));\n\n    evt.preventDefault();\n    const isInverse = evt.shiftKey;\n    const lf = state.before.lastIndexOf('\\n') + 1;\n    if (isInverse) {\n      if (/\\s/.test(state.before.charAt(lf))) {\n        state.before = strSplice(state.before, lf, 1);\n      }\n      state.selection = state.selection.replace(/^[ \\t]/gm, '');\n    } else if (state.selection) {\n      state.before = strSplice(state.before, lf, 0, '\\t');\n      state.selection = state.selection.replace(/\\n(?=[\\s\\S])/g, '\\n\\t');\n    } else {\n      state.before += '\\t';\n    }\n    return true;\n  }),\n\n  new Keystroke((evt, state, editor) => {\n    if (evt.which !== 13 /* enter */) {\n      clearNewline = false;\n      return false;\n    }\n\n    evt.preventDefault();\n    const lf = state.before.lastIndexOf('\\n') + 1;\n    if (clearNewline) {\n      state.before = state.before.substring(0, lf);\n      state.selection = '';\n      clearNewline = false;\n      return true;\n    }\n    clearNewline = false;\n    const previousLine = state.before.slice(lf);\n    const indent = previousLine.match(/^\\s*/)[0];\n    if (indent.length) {\n      clearNewline = true;\n    }\n\n    editor.undoMgr.setCurrentMode('single');\n    state.before += `\\n${indent}`;\n    state.selection = '';\n    return true;\n  }),\n\n  new Keystroke((evt, state, editor) => {\n    if (evt.which !== 8 /* backspace */ && evt.which !== 46 /* delete */) {\n      return false;\n    }\n\n    editor.undoMgr.setCurrentMode('delete');\n    if (!state.selection) {\n      const isJump = (cledit.Utils.isMac && evt.altKey) || (!cledit.Utils.isMac && evt.ctrlKey);\n      if (isJump) {\n        // Custom kill word behavior\n        const text = state.before + state.after;\n        const offset = getNextWordOffset(text, state.before.length, evt.which === 8);\n        if (evt.which === 8) {\n          state.before = state.before.slice(0, offset);\n        } else {\n          state.after = state.after.slice(offset - text.length);\n        }\n        evt.preventDefault();\n        return true;\n      } else if (evt.which === 8 && state.before.slice(-1) === '\\n') {\n        // Special treatment for end of lines\n        state.before = state.before.slice(0, -1);\n        evt.preventDefault();\n        return true;\n      } else if (evt.which === 46 && state.after.slice(0, 1) === '\\n') {\n        state.after = state.after.slice(1);\n        evt.preventDefault();\n        return true;\n      }\n    } else {\n      state.selection = '';\n      evt.preventDefault();\n      return true;\n    }\n    return false;\n  }),\n\n  new Keystroke((evt, state, editor) => {\n    if (evt.which !== 37 /* left arrow */ && evt.which !== 39 /* right arrow */) {\n      return false;\n    }\n    const isJump = (cledit.Utils.isMac && evt.altKey) || (!cledit.Utils.isMac && evt.ctrlKey);\n    if (!isJump) {\n      return false;\n    }\n\n    // Custom jump behavior\n    const textContent = editor.getContent();\n    const offset = getNextWordOffset(\n      textContent,\n      editor.selectionMgr.selectionEnd,\n      evt.which === 37,\n    );\n    if (evt.shiftKey) {\n      // rebuild the state completely\n      const min = Math.min(editor.selectionMgr.selectionStart, offset);\n      const max = Math.max(editor.selectionMgr.selectionStart, offset);\n      state.before = textContent.slice(0, min);\n      state.after = textContent.slice(max);\n      state.selection = textContent.slice(min, max);\n      state.isBackwardSelection = editor.selectionMgr.selectionStart > offset;\n    } else {\n      state.before = textContent.slice(0, offset);\n      state.after = textContent.slice(offset);\n      state.selection = '';\n    }\n    evt.preventDefault();\n    return true;\n  }),\n];\n"
  },
  {
    "path": "src/services/editor/cledit/cleditMarker.js",
    "content": "import cledit from './cleditCore';\n\nconst DIFF_DELETE = -1;\nconst DIFF_INSERT = 1;\nconst DIFF_EQUAL = 0;\n\nlet idCounter = 0;\n\nclass Marker {\n  constructor(offset, trailing) {\n    this.id = idCounter;\n    idCounter += 1;\n    this.offset = offset;\n    this.trailing = trailing;\n  }\n\n  adjustOffset(diffs) {\n    let startOffset = 0;\n    diffs.cl_each((diff) => {\n      const diffType = diff[0];\n      const diffText = diff[1];\n      const diffOffset = diffText.length;\n      switch (diffType) {\n        case DIFF_EQUAL:\n          startOffset += diffOffset;\n          break;\n        case DIFF_INSERT:\n          if (\n            this.trailing\n              ? this.offset > startOffset\n              : this.offset >= startOffset\n          ) {\n            this.offset += diffOffset;\n          }\n          startOffset += diffOffset;\n          break;\n        case DIFF_DELETE:\n          if (this.offset > startOffset) {\n            this.offset -= Math.min(diffOffset, this.offset - startOffset);\n          }\n          break;\n        default:\n      }\n    });\n  }\n}\n\n\ncledit.Marker = Marker;\n"
  },
  {
    "path": "src/services/editor/cledit/cleditSelectionMgr.js",
    "content": "import cledit from './cleditCore';\n\nfunction SelectionMgr(editor) {\n  const { debounce } = cledit.Utils;\n  const contentElt = editor.$contentElt;\n  const scrollElt = editor.$scrollElt;\n  cledit.Utils.createEventHooks(this);\n\n  let lastSelectionStart = 0;\n  let lastSelectionEnd = 0;\n  this.selectionStart = 0;\n  this.selectionEnd = 0;\n  this.cursorCoordinates = {};\n\n  this.findContainer = (offset) => {\n    const result = cledit.Utils.findContainer(contentElt, offset);\n    if (result.container.nodeValue === '\\n') {\n      const hdLfElt = result.container.parentNode;\n      if (hdLfElt.className === 'hd-lf' && hdLfElt.previousSibling && hdLfElt.previousSibling.tagName === 'BR') {\n        result.container = hdLfElt.parentNode;\n        result.offsetInContainer = Array.prototype.indexOf.call(\n          result.container.childNodes,\n          result.offsetInContainer === 0 ? hdLfElt.previousSibling : hdLfElt,\n        );\n      }\n    }\n    return result;\n  };\n\n  this.createRange = (start, end) => {\n    const range = document.createRange();\n    const startContainer = typeof start === 'number'\n      ? this.findContainer(start < 0 ? 0 : start)\n      : start;\n    let endContainer = startContainer;\n    if (start !== end) {\n      endContainer = typeof end === 'number'\n        ? this.findContainer(end < 0 ? 0 : end)\n        : end;\n    }\n    range.setStart(startContainer.container, startContainer.offsetInContainer);\n    range.setEnd(endContainer.container, endContainer.offsetInContainer);\n    return range;\n  };\n\n  let adjustScroll;\n  const debouncedUpdateCursorCoordinates = debounce(() => {\n    const coordinates = this.getCoordinates(\n      this.selectionEnd,\n      this.selectionEndContainer,\n      this.selectionEndOffset,\n    );\n    if (this.cursorCoordinates.top !== coordinates.top ||\n      this.cursorCoordinates.height !== coordinates.height ||\n      this.cursorCoordinates.left !== coordinates.left\n    ) {\n      this.cursorCoordinates = coordinates;\n      this.$trigger('cursorCoordinatesChanged', coordinates);\n    }\n    if (adjustScroll) {\n      let scrollEltHeight = scrollElt.clientHeight;\n      if (typeof adjustScroll === 'number') {\n        scrollEltHeight -= adjustScroll;\n      }\n      const adjustment = (scrollEltHeight / 2) * editor.options.getCursorFocusRatio();\n      let cursorTop = this.cursorCoordinates.top + (this.cursorCoordinates.height / 2);\n      // Adjust cursorTop with contentElt position relative to scrollElt\n      cursorTop += (contentElt.getBoundingClientRect().top - scrollElt.getBoundingClientRect().top)\n        + scrollElt.scrollTop;\n      const minScrollTop = cursorTop - adjustment;\n      const maxScrollTop = (cursorTop + adjustment) - scrollEltHeight;\n      if (scrollElt.scrollTop > minScrollTop) {\n        scrollElt.scrollTop = minScrollTop;\n      } else if (scrollElt.scrollTop < maxScrollTop) {\n        scrollElt.scrollTop = maxScrollTop;\n      }\n    }\n    adjustScroll = false;\n  });\n\n  this.updateCursorCoordinates = (adjustScrollParam) => {\n    adjustScroll = adjustScroll || adjustScrollParam;\n    debouncedUpdateCursorCoordinates();\n  };\n\n  let oldSelectionRange;\n\n  const checkSelection = (selectionRange) => {\n    if (!oldSelectionRange ||\n      oldSelectionRange.startContainer !== selectionRange.startContainer ||\n      oldSelectionRange.startOffset !== selectionRange.startOffset ||\n      oldSelectionRange.endContainer !== selectionRange.endContainer ||\n      oldSelectionRange.endOffset !== selectionRange.endOffset\n    ) {\n      oldSelectionRange = selectionRange;\n      this.$trigger('selectionChanged', this.selectionStart, this.selectionEnd, selectionRange);\n      return true;\n    }\n    return false;\n  };\n\n  this.hasFocus = () => contentElt === document.activeElement;\n\n  this.restoreSelection = () => {\n    const min = Math.min(this.selectionStart, this.selectionEnd);\n    const max = Math.max(this.selectionStart, this.selectionEnd);\n    const selectionRange = this.createRange(min, max);\n    if (!document.contains(selectionRange.commonAncestorContainer)) {\n      return null;\n    }\n    const selection = window.getSelection();\n    selection.removeAllRanges();\n    const isBackward = this.selectionStart > this.selectionEnd;\n    if (isBackward && selection.extend) {\n      const beginRange = selectionRange.cloneRange();\n      beginRange.collapse(false);\n      selection.addRange(beginRange);\n      selection.extend(selectionRange.startContainer, selectionRange.startOffset);\n    } else {\n      selection.addRange(selectionRange);\n    }\n    checkSelection(selectionRange);\n    return selectionRange;\n  };\n\n  const saveLastSelection = debounce(() => {\n    lastSelectionStart = this.selectionStart;\n    lastSelectionEnd = this.selectionEnd;\n  }, 50);\n\n  const setSelection = (start = this.selectionStart, end = this.selectionEnd) => {\n    this.selectionStart = start < 0 ? 0 : start;\n    this.selectionEnd = end < 0 ? 0 : end;\n    saveLastSelection();\n  };\n\n  this.setSelectionStartEnd = (start, end, restoreSelection = true) => {\n    setSelection(start, end);\n    if (restoreSelection && this.hasFocus()) {\n      return this.restoreSelection();\n    }\n    return null;\n  };\n\n  this.saveSelectionState = (() => {\n    // Credit: https://github.com/timdown/rangy\n    function arrayContains(arr, val) {\n      let i = arr.length;\n      while (i) {\n        i -= 1;\n        if (arr[i] === val) {\n          return true;\n        }\n      }\n      return false;\n    }\n\n    function getClosestAncestorIn(node, ancestor, selfIsAncestor) {\n      let p;\n      let n = selfIsAncestor ? node : node.parentNode;\n      while (n) {\n        p = n.parentNode;\n        if (p === ancestor) {\n          return n;\n        }\n        n = p;\n      }\n      return null;\n    }\n\n    function getNodeIndex(node) {\n      let i = 0;\n      let { previousSibling } = node;\n      while (previousSibling) {\n        i += 1;\n        ({ previousSibling } = previousSibling);\n      }\n      return i;\n    }\n\n    function getCommonAncestor(node1, node2) {\n      const ancestors = [];\n      let n;\n      for (n = node1; n; n = n.parentNode) {\n        ancestors.push(n);\n      }\n\n      for (n = node2; n; n = n.parentNode) {\n        if (arrayContains(ancestors, n)) {\n          return n;\n        }\n      }\n\n      return null;\n    }\n\n    function comparePoints(nodeA, offsetA, nodeB, offsetB) {\n      // See http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Comparing\n      let n;\n      if (nodeA === nodeB) {\n        // Case 1: nodes are the same\n        if (offsetA === offsetB) {\n          return 0;\n        }\n        return offsetA < offsetB ? -1 : 1;\n      }\n      let nodeC = getClosestAncestorIn(nodeB, nodeA, true);\n      if (nodeC) {\n        // Case 2: node C (container B or an ancestor) is a child node of A\n        return offsetA <= getNodeIndex(nodeC) ? -1 : 1;\n      }\n      nodeC = getClosestAncestorIn(nodeA, nodeB, true);\n      if (nodeC) {\n        // Case 3: node C (container A or an ancestor) is a child node of B\n        return getNodeIndex(nodeC) < offsetB ? -1 : 1;\n      }\n      const root = getCommonAncestor(nodeA, nodeB);\n      if (!root) {\n        throw new Error('comparePoints error: nodes have no common ancestor');\n      }\n\n      // Case 4: containers are siblings or descendants of siblings\n      const childA = (nodeA === root) ? root : getClosestAncestorIn(nodeA, root, true);\n      const childB = (nodeB === root) ? root : getClosestAncestorIn(nodeB, root, true);\n\n      if (childA === childB) {\n        // This shouldn't be possible\n        throw module.createError('comparePoints got to case 4 and childA and childB are the same!');\n      }\n      n = root.firstChild;\n      while (n) {\n        if (n === childA) {\n          return -1;\n        } else if (n === childB) {\n          return 1;\n        }\n        n = n.nextSibling;\n      }\n      return 0;\n    }\n\n    const save = () => {\n      let result;\n      if (this.hasFocus()) {\n        let { selectionStart } = this;\n        let { selectionEnd } = this;\n        const selection = window.getSelection();\n        if (selection.rangeCount > 0) {\n          const selectionRange = selection.getRangeAt(0);\n          let node = selectionRange.startContainer;\n          // eslint-disable-next-line no-bitwise\n          if ((contentElt.compareDocumentPosition(node)\n            & window.Node.DOCUMENT_POSITION_CONTAINED_BY)\n            || contentElt === node\n          ) {\n            let offset = selectionRange.startOffset;\n            if (node.firstChild && offset > 0) {\n              node = node.childNodes[offset - 1];\n              offset = node.textContent.length;\n            }\n            let container = node;\n            while (node !== contentElt) {\n              node = node.previousSibling;\n              while (node) {\n                offset += (node.textContent || '').length;\n                node = node.previousSibling;\n              }\n              node = container.parentNode;\n              container = node;\n            }\n            let selectionText = `${selectionRange}`;\n            // Fix end of line when only br is selected\n            const brElt = selectionRange.endContainer.firstChild;\n            if (brElt && brElt.tagName === 'BR' && selectionRange.endOffset === 1) {\n              selectionText += '\\n';\n            }\n            if (comparePoints(\n              selection.anchorNode,\n              selection.anchorOffset,\n              selection.focusNode,\n              selection.focusOffset,\n            ) === 1) {\n              selectionStart = offset + selectionText.length;\n              selectionEnd = offset;\n            } else {\n              selectionStart = offset;\n              selectionEnd = offset + selectionText.length;\n            }\n\n            if (selectionStart === selectionEnd && selectionStart === editor.getContent().length) {\n              // If cursor is after the trailingNode\n              selectionEnd -= 1;\n              selectionStart = selectionEnd;\n              result = this.setSelectionStartEnd(selectionStart, selectionEnd);\n            } else {\n              setSelection(selectionStart, selectionEnd);\n              result = checkSelection(selectionRange);\n              // selectionRange doesn't change when selection is at the start of a section\n              result = result || lastSelectionStart !== this.selectionStart;\n            }\n          }\n        }\n      }\n      return result;\n    };\n\n    const saveCheckChange = () => save() && (\n      lastSelectionStart !== this.selectionStart || lastSelectionEnd !== this.selectionEnd);\n\n    let nextTickAdjustScroll = false;\n    const longerDebouncedSave = debounce(() => {\n      this.updateCursorCoordinates(saveCheckChange() && nextTickAdjustScroll);\n      nextTickAdjustScroll = false;\n    }, 10);\n    const debouncedSave = debounce(() => {\n      this.updateCursorCoordinates(saveCheckChange() && nextTickAdjustScroll);\n      // In some cases we have to wait a little longer to see the\n      // selection change (Cmd+A on Chrome OSX)\n      longerDebouncedSave();\n    });\n\n    return (debounced, adjustScrollParam, forceAdjustScroll) => {\n      if (forceAdjustScroll) {\n        lastSelectionStart = undefined;\n        lastSelectionEnd = undefined;\n      }\n      if (debounced) {\n        nextTickAdjustScroll = nextTickAdjustScroll || adjustScrollParam;\n        debouncedSave();\n      } else {\n        save();\n      }\n    };\n  })();\n\n  this.getSelectedText = () => {\n    const min = Math.min(this.selectionStart, this.selectionEnd);\n    const max = Math.max(this.selectionStart, this.selectionEnd);\n    return editor.getContent().substring(min, max);\n  };\n\n  this.getCoordinates = (inputOffset, containerParam, offsetInContainerParam) => {\n    let container = containerParam;\n    let offsetInContainer = offsetInContainerParam;\n    if (!container) {\n      const offset = this.findContainer(inputOffset);\n      ({ container } = offset);\n      ({ offsetInContainer } = offset);\n    }\n    let containerElt = container;\n    if (!containerElt.hasChildNodes() && container.parentNode) {\n      containerElt = container.parentNode;\n    }\n    let isInvisible = false;\n    while (!containerElt.offsetHeight) {\n      isInvisible = true;\n      if (containerElt.previousSibling) {\n        containerElt = containerElt.previousSibling;\n      } else {\n        containerElt = containerElt.parentNode;\n        if (!containerElt) {\n          return {\n            top: 0,\n            height: 0,\n            left: 0,\n          };\n        }\n      }\n    }\n    let rect;\n    let left = 'left';\n    if (isInvisible || container.textContent === '\\n') {\n      rect = containerElt.getBoundingClientRect();\n    } else {\n      const selectedChar = editor.getContent()[inputOffset];\n      let startOffset = {\n        container,\n        offsetInContainer,\n      };\n      let endOffset = {\n        container,\n        offsetInContainer,\n      };\n      if (inputOffset > 0 && (selectedChar === undefined || selectedChar === '\\n')) {\n        left = 'right';\n        if (startOffset.offsetInContainer === 0) {\n          // Need to calculate offset-1\n          startOffset = inputOffset - 1;\n        } else {\n          startOffset.offsetInContainer -= 1;\n        }\n      } else if (endOffset.offsetInContainer === container.textContent.length) {\n        // Need to calculate offset+1\n        endOffset = inputOffset + 1;\n      } else {\n        endOffset.offsetInContainer += 1;\n      }\n      const range = this.createRange(startOffset, endOffset);\n      rect = range.getBoundingClientRect();\n    }\n    const contentRect = contentElt.getBoundingClientRect();\n    return {\n      top: Math.round((rect.top - contentRect.top) + contentElt.scrollTop),\n      height: Math.round(rect.height),\n      left: Math.round((rect[left] - contentRect.left) + contentElt.scrollLeft),\n    };\n  };\n\n  this.getClosestWordOffset = (offset) => {\n    let offsetStart = 0;\n    let offsetEnd = 0;\n    let nextOffset = 0;\n    editor.getContent().split(/\\s/).cl_some((word) => {\n      if (word) {\n        offsetStart = nextOffset;\n        offsetEnd = nextOffset + word.length;\n        if (offsetEnd > offset) {\n          return true;\n        }\n      }\n      nextOffset += word.length + 1;\n      return false;\n    });\n    return {\n      start: offsetStart,\n      end: offsetEnd,\n    };\n  };\n}\n\ncledit.SelectionMgr = SelectionMgr;\n"
  },
  {
    "path": "src/services/editor/cledit/cleditUndoMgr.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport cledit from './cleditCore';\n\nfunction UndoMgr(editor) {\n  cledit.Utils.createEventHooks(this);\n\n  /* eslint-disable new-cap */\n  const diffMatchPatch = new DiffMatchPatch();\n  /* eslint-enable new-cap */\n\n  const self = this;\n  let selectionMgr;\n  const undoStack = [];\n  const redoStack = [];\n  let currentState;\n  let previousPatches = [];\n  let currentPatches = [];\n  const { debounce } = cledit.Utils;\n\n  this.options = {\n    undoStackMaxSize: 200,\n    bufferStateUntilIdle: 1000,\n    patchHandler: {\n      makePatches(oldContent, newContent, diffs) {\n        return diffMatchPatch.patch_make(oldContent, diffs);\n      },\n      applyPatches(patches, content) {\n        return diffMatchPatch.patch_apply(patches, content)[0];\n      },\n      reversePatches(patches) {\n        const reversedPatches = diffMatchPatch.patch_deepCopy(patches).reverse();\n        reversedPatches.cl_each((patch) => {\n          patch.diffs.cl_each((diff) => {\n            diff[0] = -diff[0];\n          });\n        });\n        return reversedPatches;\n      },\n    },\n  };\n\n  let stateMgr;\n  function StateMgr() {\n    let currentTime;\n    let lastTime;\n    let lastMode;\n\n    this.isBufferState = () => {\n      currentTime = Date.now();\n      return this.currentMode !== 'single' &&\n        this.currentMode === lastMode &&\n        currentTime - lastTime < self.options.bufferStateUntilIdle;\n    };\n\n    this.setDefaultMode = (mode) => {\n      this.currentMode = this.currentMode || mode;\n    };\n\n    this.resetMode = () => {\n      stateMgr.currentMode = undefined;\n      lastMode = undefined;\n    };\n\n    this.saveMode = () => {\n      lastMode = this.currentMode;\n      this.currentMode = undefined;\n      lastTime = currentTime;\n    };\n  }\n\n  class State {\n    addToUndoStack() {\n      undoStack.push(this);\n      this.patches = previousPatches;\n      previousPatches = [];\n    }\n    addToRedoStack() {\n      redoStack.push(this);\n      this.patches = previousPatches;\n      previousPatches = [];\n    }\n  }\n\n  stateMgr = new StateMgr();\n  this.setCurrentMode = (mode) => {\n    stateMgr.currentMode = mode;\n  };\n  this.setDefaultMode = stateMgr.setDefaultMode.cl_bind(stateMgr);\n\n  this.addDiffs = (oldContent, newContent, diffs) => {\n    const patches = this.options.patchHandler.makePatches(oldContent, newContent, diffs);\n    patches.cl_each(patch => currentPatches.push(patch));\n  };\n\n  function saveCurrentPatches() {\n    // Move currentPatches into previousPatches\n    Array.prototype.push.apply(previousPatches, currentPatches);\n    currentPatches = [];\n  }\n\n  this.saveState = debounce(() => {\n    redoStack.length = 0;\n    if (!stateMgr.isBufferState()) {\n      currentState.addToUndoStack();\n\n      // Limit the size of the stack\n      while (undoStack.length > this.options.undoStackMaxSize) {\n        undoStack.shift();\n      }\n    }\n    saveCurrentPatches();\n    currentState = new State();\n    stateMgr.saveMode();\n    this.$trigger('undoStateChange');\n  });\n\n  this.canUndo = () => !!undoStack.length;\n  this.canRedo = () => !!redoStack.length;\n\n  const restoreState = (patchesParam, isForward) => {\n    let patches = patchesParam;\n    // Update editor\n    const content = editor.getContent();\n    if (!isForward) {\n      patches = this.options.patchHandler.reversePatches(patches);\n    }\n\n    const newContent = this.options.patchHandler.applyPatches(patches, content);\n    const newContentText = newContent.text || newContent;\n    const range = editor.setContent(newContentText, true);\n    const selection = newContent.selection || {\n      start: range.end,\n      end: range.end,\n    };\n\n    selectionMgr.setSelectionStartEnd(selection.start, selection.end);\n    selectionMgr.updateCursorCoordinates(true);\n\n    stateMgr.resetMode();\n    this.$trigger('undoStateChange');\n    editor.adjustCursorPosition();\n  };\n\n  this.undo = () => {\n    const state = undoStack.pop();\n    if (!state) {\n      return;\n    }\n    saveCurrentPatches();\n    currentState.addToRedoStack();\n    restoreState(currentState.patches);\n    previousPatches = state.patches;\n    currentState = state;\n  };\n\n  this.redo = () => {\n    const state = redoStack.pop();\n    if (!state) {\n      return;\n    }\n    currentState.addToUndoStack();\n    restoreState(state.patches, true);\n    previousPatches = state.patches;\n    currentState = state;\n  };\n\n  this.init = (options) => {\n    this.options.cl_extend(options || {});\n    ({ selectionMgr } = editor);\n    if (!currentState) {\n      currentState = new State();\n    }\n  };\n}\n\ncledit.UndoMgr = UndoMgr;\n"
  },
  {
    "path": "src/services/editor/cledit/cleditUtils.js",
    "content": "import cledit from './cleditCore';\n\nconst Utils = {\n  isGecko: 'MozAppearance' in document.documentElement.style,\n  isWebkit: 'WebkitAppearance' in document.documentElement.style,\n  isMsie: 'msTransform' in document.documentElement.style,\n  isMac: navigator.userAgent.indexOf('Mac OS X') !== -1,\n};\n\n// Faster than setTimeout(0). Credit: https://github.com/stefanpenner/es6-promise\nUtils.defer = (() => {\n  const queue = new Array(1000);\n  let queueLength = 0;\n  function flush() {\n    for (let i = 0; i < queueLength; i += 1) {\n      try {\n        queue[i]();\n      } catch (e) {\n        // eslint-disable-next-line no-console\n        console.error(e.message, e.stack);\n      }\n      queue[i] = undefined;\n    }\n    queueLength = 0;\n  }\n\n  let iterations = 0;\n  const observer = new window.MutationObserver(flush);\n  const node = document.createTextNode('');\n  observer.observe(node, { characterData: true });\n\n  return (fn) => {\n    queue[queueLength] = fn;\n    queueLength += 1;\n    if (queueLength === 1) {\n      iterations = (iterations + 1) % 2;\n      node.data = iterations;\n    }\n  };\n})();\n\nUtils.debounce = (func, wait) => {\n  let timeoutId;\n  let isExpected;\n  return wait\n    ? () => {\n      clearTimeout(timeoutId);\n      timeoutId = setTimeout(func, wait);\n    }\n    : () => {\n      if (!isExpected) {\n        isExpected = true;\n        Utils.defer(() => {\n          isExpected = false;\n          func();\n        });\n      }\n    };\n};\n\nUtils.createEventHooks = (object) => {\n  const listenerMap = Object.create(null);\n  object.$trigger = (eventType, ...args) => {\n    const listeners = listenerMap[eventType];\n    if (listeners) {\n      listeners.cl_each((listener) => {\n        try {\n          listener.apply(object, args);\n        } catch (e) {\n          // eslint-disable-next-line no-console\n          console.error(e.message, e.stack);\n        }\n      });\n    }\n  };\n  object.on = (eventType, listener) => {\n    let listeners = listenerMap[eventType];\n    if (!listeners) {\n      listeners = [];\n      listenerMap[eventType] = listeners;\n    }\n    listeners.push(listener);\n  };\n  object.off = (eventType, listener) => {\n    const listeners = listenerMap[eventType];\n    if (listeners) {\n      const index = listeners.indexOf(listener);\n      if (index !== -1) {\n        listeners.splice(index, 1);\n      }\n    }\n  };\n};\n\nUtils.findContainer = (elt, offset) => {\n  let containerOffset = 0;\n  let container;\n  let child = elt;\n  do {\n    container = child;\n    child = child.firstChild;\n    if (child) {\n      do {\n        const len = child.textContent.length;\n        if (containerOffset <= offset && containerOffset + len > offset) {\n          break;\n        }\n        containerOffset += len;\n        child = child.nextSibling;\n      } while (child);\n    }\n  } while (child && child.firstChild && child.nodeType !== 3);\n\n  if (child) {\n    return {\n      container: child,\n      offsetInContainer: offset - containerOffset,\n    };\n  }\n  while (container.lastChild) {\n    container = container.lastChild;\n  }\n  return {\n    container,\n    offsetInContainer: container.nodeType === 3 ? container.textContent.length : 0,\n  };\n};\n\ncledit.Utils = Utils;\n"
  },
  {
    "path": "src/services/editor/cledit/cleditWatcher.js",
    "content": "import cledit from './cleditCore';\n\nfunction Watcher(editor, listener) {\n  this.isWatching = false;\n  let contentObserver;\n  this.startWatching = () => {\n    this.stopWatching();\n    this.isWatching = true;\n    contentObserver = new window.MutationObserver(listener);\n    contentObserver.observe(editor.$contentElt, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n    });\n  };\n  this.stopWatching = () => {\n    if (contentObserver) {\n      contentObserver.disconnect();\n      contentObserver = undefined;\n    }\n    this.isWatching = false;\n  };\n  this.noWatch = (cb) => {\n    if (this.isWatching === true) {\n      this.stopWatching();\n      cb();\n      this.startWatching();\n    } else {\n      cb();\n    }\n  };\n}\n\ncledit.Watcher = Watcher;\n"
  },
  {
    "path": "src/services/editor/cledit/index.js",
    "content": "import '../../../libs/clunderscore';\nimport cledit from './cleditCore';\nimport './cleditHighlighter';\nimport './cleditKeystroke';\nimport './cleditMarker';\nimport './cleditSelectionMgr';\nimport './cleditUndoMgr';\nimport './cleditUtils';\nimport './cleditWatcher';\n\nexport default cledit;\n"
  },
  {
    "path": "src/services/editor/editorSvcDiscussions.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport cledit from './cledit';\nimport utils from '../utils';\nimport diffUtils from '../diffUtils';\nimport store from '../../store';\nimport EditorClassApplier from '../../components/common/EditorClassApplier';\nimport PreviewClassApplier from '../../components/common/PreviewClassApplier';\n\nlet clEditor;\n// let discussionIds = {};\nlet discussionMarkers = {};\nlet markerKeys;\nlet markerIdxMap;\nlet previousPatchableText;\nlet currentPatchableText;\nlet isChangePatch;\nlet contentId;\nlet editorClassAppliers = {};\nlet previewClassAppliers = {};\n\nfunction getDiscussionMarkers(discussion, discussionId, onMarker) {\n  const getMarker = (offsetName) => {\n    const markerKey = `${discussionId}:${offsetName}`;\n    let marker = discussionMarkers[markerKey];\n    if (!marker) {\n      marker = new cledit.Marker(discussion[offsetName], offsetName === 'end');\n      marker.discussionId = discussionId;\n      marker.offsetName = offsetName;\n      clEditor.addMarker(marker);\n      discussionMarkers[markerKey] = marker;\n    }\n    onMarker(marker);\n  };\n  getMarker('start');\n  getMarker('end');\n}\n\nfunction syncDiscussionMarkers(content, writeOffsets) {\n  const discussions = {\n    ...content.discussions,\n  };\n  const newDiscussion = store.getters['discussion/newDiscussion'];\n  if (newDiscussion) {\n    discussions[store.state.discussion.newDiscussionId] = {\n      ...newDiscussion,\n    };\n  }\n  Object.entries(discussionMarkers).forEach(([markerKey, marker]) => {\n    // Remove marker if discussion was removed\n    const discussion = discussions[marker.discussionId];\n    if (!discussion) {\n      clEditor.removeMarker(marker);\n      delete discussionMarkers[markerKey];\n    }\n  });\n\n  Object.entries(discussions).forEach(([discussionId, discussion]) => {\n    getDiscussionMarkers(discussion, discussionId, writeOffsets\n      ? (marker) => {\n        discussion[marker.offsetName] = marker.offset;\n      }\n      : (marker) => {\n        marker.offset = discussion[marker.offsetName];\n      });\n  });\n\n  if (writeOffsets && newDiscussion) {\n    store.commit(\n      'discussion/patchNewDiscussion',\n      discussions[store.state.discussion.newDiscussionId],\n    );\n  }\n}\n\nfunction removeDiscussionMarkers() {\n  Object.entries(discussionMarkers).forEach(([, marker]) => {\n    clEditor.removeMarker(marker);\n  });\n  discussionMarkers = {};\n  markerKeys = [];\n  markerIdxMap = Object.create(null);\n}\n\nconst diffMatchPatch = new DiffMatchPatch();\n\nfunction makePatches() {\n  const diffs = diffMatchPatch.diff_main(previousPatchableText, currentPatchableText);\n  return diffMatchPatch.patch_make(previousPatchableText, diffs);\n}\n\nfunction applyPatches(patches) {\n  const newPatchableText = diffMatchPatch.patch_apply(patches, currentPatchableText)[0];\n  let result = newPatchableText;\n  if (markerKeys.length) {\n    // Strip text markers\n    result = result.replace(new RegExp(`[\\ue000-${String.fromCharCode((0xe000 + markerKeys.length) - 1)}]`, 'g'), '');\n  }\n  // Expect a `contentChanged` event\n  if (result !== clEditor.getContent()) {\n    previousPatchableText = currentPatchableText;\n    currentPatchableText = newPatchableText;\n    isChangePatch = true;\n  }\n  return result;\n}\n\nfunction reversePatches(patches) {\n  const result = diffMatchPatch.patch_deepCopy(patches).reverse();\n  result.forEach((patch) => {\n    patch.diffs.forEach((diff) => {\n      diff[0] = -diff[0];\n    });\n  });\n  return result;\n}\n\nexport default {\n  createClEditor(editorElt) {\n    this.clEditor = cledit(editorElt, editorElt.parentNode, true);\n    ({ clEditor } = this);\n    clEditor.on('contentChanged', (text) => {\n      const oldContent = store.getters['content/current'];\n      const newContent = {\n        ...utils.deepCopy(oldContent),\n        text: utils.sanitizeText(text),\n      };\n      syncDiscussionMarkers(newContent, true);\n      if (!isChangePatch) {\n        previousPatchableText = currentPatchableText;\n        currentPatchableText = diffUtils.makePatchableText(newContent, markerKeys, markerIdxMap);\n      } else {\n        // Take a chance to restore discussion offsets on undo/redo\n        newContent.text = currentPatchableText;\n        diffUtils.restoreDiscussionOffsets(newContent, markerKeys);\n        syncDiscussionMarkers(newContent, false);\n      }\n      store.dispatch('content/patchCurrent', newContent);\n      isChangePatch = false;\n    });\n    clEditor.on('focus', () => store.commit('discussion/setNewCommentFocus', false));\n  },\n  initClEditorInternal(opts) {\n    const content = store.getters['content/current'];\n    if (content) {\n      removeDiscussionMarkers(); // Markers will be recreated on contentChanged\n      const contentState = store.getters['contentState/current'];\n      const options = Object.assign({\n        selectionStart: contentState.selectionStart,\n        selectionEnd: contentState.selectionEnd,\n        patchHandler: {\n          makePatches,\n          applyPatches,\n          reversePatches,\n        },\n      }, opts);\n\n      if (contentId !== content.id) {\n        contentId = content.id;\n        currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap);\n        previousPatchableText = currentPatchableText;\n        syncDiscussionMarkers(content, false);\n        options.content = content.text;\n      }\n\n      clEditor.init(options);\n    }\n  },\n  applyContent() {\n    if (clEditor) {\n      const content = store.getters['content/current'];\n      if (clEditor.setContent(content.text, true).range) {\n        // Marker will be recreated on contentChange\n        removeDiscussionMarkers();\n      } else {\n        syncDiscussionMarkers(content, false);\n      }\n    }\n  },\n  getTrimmedSelection() {\n    const { selectionMgr } = clEditor;\n    let start = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd);\n    let end = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd);\n    const text = clEditor.getContent();\n    while ((text[start] || '').match(/\\s/)) {\n      start += 1;\n    }\n    while ((text[end - 1] || '').match(/\\s/)) {\n      end -= 1;\n    }\n    return start < end && { start, end };\n  },\n  initHighlighters() {\n    store.watch(\n      () => store.getters['discussion/newDiscussion'],\n      () => syncDiscussionMarkers(store.getters['content/current'], false),\n    );\n\n    store.watch(\n      () => store.getters['discussion/currentFileDiscussions'],\n      (discussions) => {\n        const classGetter = (type, discussionId) => () => {\n          const classes = [`discussion-${type}-highlighting--${discussionId}`, `discussion-${type}-highlighting`];\n          if (store.state.discussion.currentDiscussionId === discussionId) {\n            classes.push(`discussion-${type}-highlighting--selected`);\n          }\n          return classes;\n        };\n        const offsetGetter = discussionId => () => {\n          const startMarker = discussionMarkers[`${discussionId}:start`];\n          const endMarker = discussionMarkers[`${discussionId}:end`];\n          return startMarker && endMarker && {\n            start: startMarker.offset,\n            end: endMarker.offset,\n          };\n        };\n\n        // Editor class appliers\n        const oldEditorClassAppliers = editorClassAppliers;\n        editorClassAppliers = {};\n        Object.keys(discussions).forEach((discussionId) => {\n          const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier(\n            classGetter('editor', discussionId),\n            offsetGetter(discussionId),\n            { discussionId },\n          );\n          editorClassAppliers[discussionId] = classApplier;\n        });\n        // Clean unused class appliers\n        Object.entries(oldEditorClassAppliers).forEach(([discussionId, classApplier]) => {\n          if (!editorClassAppliers[discussionId]) {\n            classApplier.stop();\n          }\n        });\n\n        // Preview class appliers\n        const oldPreviewClassAppliers = previewClassAppliers;\n        previewClassAppliers = {};\n        Object.keys(discussions).forEach((discussionId) => {\n          const classApplier = oldPreviewClassAppliers[discussionId] || new PreviewClassApplier(\n            classGetter('preview', discussionId),\n            offsetGetter(discussionId),\n            { discussionId },\n          );\n          previewClassAppliers[discussionId] = classApplier;\n        });\n        // Clean unused class appliers\n        Object.entries(oldPreviewClassAppliers).forEach(([discussionId, classApplier]) => {\n          if (!previewClassAppliers[discussionId]) {\n            classApplier.stop();\n          }\n        });\n      },\n    );\n  },\n};\n\n"
  },
  {
    "path": "src/services/editor/editorSvcUtils.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport cledit from './cledit';\nimport animationSvc from '../animationSvc';\nimport store from '../../store';\n\nconst diffMatchPatch = new DiffMatchPatch();\n\nexport default {\n  /**\n   * Get an object describing the position of the scroll bar in the file.\n   */\n  getScrollPosition(elt = store.getters['layout/styles'].showEditor\n    ? this.editorElt : this.previewElt) {\n    const dimensionKey = elt === this.editorElt\n      ? 'editorDimension'\n      : 'previewDimension';\n    const { scrollTop } = elt.parentNode;\n    let result;\n    if (this.previewCtxMeasured) {\n      this.previewCtxMeasured.sectionDescList.some((sectionDesc, sectionIdx) => {\n        if (scrollTop >= sectionDesc[dimensionKey].endOffset) {\n          return false;\n        }\n        const posInSection = (scrollTop - sectionDesc[dimensionKey].startOffset) /\n          (sectionDesc[dimensionKey].height || 1);\n        result = {\n          sectionIdx,\n          posInSection,\n        };\n        return true;\n      });\n    }\n    return result;\n  },\n\n  /**\n   * Restore the scroll position from the current file content state.\n   */\n  restoreScrollPosition() {\n    const { scrollPosition } = store.getters['contentState/current'];\n    if (scrollPosition && this.previewCtxMeasured) {\n      const sectionDesc = this.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx];\n      if (sectionDesc) {\n        const editorScrollTop = sectionDesc.editorDimension.startOffset +\n          (sectionDesc.editorDimension.height * scrollPosition.posInSection);\n        this.editorElt.parentNode.scrollTop = Math.floor(editorScrollTop);\n        const previewScrollTop = sectionDesc.previewDimension.startOffset +\n          (sectionDesc.previewDimension.height * scrollPosition.posInSection);\n        this.previewElt.parentNode.scrollTop = Math.floor(previewScrollTop);\n      }\n    }\n  },\n\n  /**\n   * Get the offset in the preview corresponding to the offset of the markdown in the editor\n   */\n  getPreviewOffset(\n    editorOffset,\n    sectionDescList = (this.previewCtxWithDiffs || {}).sectionDescList,\n  ) {\n    if (!sectionDescList) {\n      return null;\n    }\n    let offset = editorOffset;\n    let previewOffset = 0;\n    sectionDescList.some((sectionDesc) => {\n      if (!sectionDesc.textToPreviewDiffs) {\n        previewOffset = null;\n        return true;\n      }\n      if (sectionDesc.section.text.length >= offset) {\n        previewOffset += diffMatchPatch.diff_xIndex(sectionDesc.textToPreviewDiffs, offset);\n        return true;\n      }\n      offset -= sectionDesc.section.text.length;\n      previewOffset += sectionDesc.previewText.length;\n      return false;\n    });\n    return previewOffset;\n  },\n\n  /**\n   * Get the offset of the markdown in the editor corresponding to the offset in the preview\n   */\n  getEditorOffset(\n    previewOffset,\n    sectionDescList = (this.previewCtxWithDiffs || {}).sectionDescList,\n  ) {\n    if (!sectionDescList) {\n      return null;\n    }\n    let offset = previewOffset;\n    let editorOffset = 0;\n    sectionDescList.some((sectionDesc) => {\n      if (!sectionDesc.textToPreviewDiffs) {\n        editorOffset = null;\n        return true;\n      }\n      if (sectionDesc.previewText.length >= offset) {\n        const previewToTextDiffs = sectionDesc.textToPreviewDiffs\n          .map(diff => [-diff[0], diff[1]]);\n        editorOffset += diffMatchPatch.diff_xIndex(previewToTextDiffs, offset);\n        return true;\n      }\n      offset -= sectionDesc.previewText.length;\n      editorOffset += sectionDesc.section.text.length;\n      return false;\n    });\n    return editorOffset;\n  },\n\n  /**\n   * Get the coordinates of an offset in the preview\n   */\n  getPreviewOffsetCoordinates(offset) {\n    const start = cledit.Utils.findContainer(this.previewElt, offset && offset - 1);\n    const end = cledit.Utils.findContainer(this.previewElt, offset || offset + 1);\n    const range = document.createRange();\n    range.setStart(start.container, start.offsetInContainer);\n    range.setEnd(end.container, end.offsetInContainer);\n    const rect = range.getBoundingClientRect();\n    const contentRect = this.previewElt.getBoundingClientRect();\n    return {\n      top: Math.round((rect.top - contentRect.top) + this.previewElt.scrollTop),\n      height: Math.round(rect.height),\n      left: Math.round((rect.right - contentRect.left) + this.previewElt.scrollLeft),\n    };\n  },\n\n  /**\n   * Scroll the preview (or the editor if preview is hidden) to the specified anchor\n   */\n  scrollToAnchor(anchor) {\n    let scrollTop = 0;\n    const scrollerElt = this.previewElt.parentNode;\n    const elt = document.getElementById(anchor);\n    if (elt) {\n      scrollTop = elt.offsetTop;\n    }\n    const maxScrollTop = scrollerElt.scrollHeight - scrollerElt.offsetHeight;\n    if (scrollTop < 0) {\n      scrollTop = 0;\n    } else if (scrollTop > maxScrollTop) {\n      scrollTop = maxScrollTop;\n    }\n    animationSvc.animate(scrollerElt)\n      .scrollTop(scrollTop)\n      .duration(360)\n      .start();\n  },\n};\n"
  },
  {
    "path": "src/services/editor/sectionUtils.js",
    "content": "class SectionDimension {\n  constructor(startOffset, endOffset) {\n    this.startOffset = startOffset;\n    this.endOffset = endOffset;\n    this.height = endOffset - startOffset;\n  }\n}\n\nconst dimensionNormalizer = dimensionName => (editorSvc) => {\n  const dimensionList = editorSvc.previewCtx.sectionDescList\n    .map(sectionDesc => sectionDesc[dimensionName]);\n  let dimension;\n  let i;\n  let j;\n  for (i = 0; i < dimensionList.length; i += 1) {\n    dimension = dimensionList[i];\n    if (dimension.height) {\n      for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) {\n        // Loop\n      }\n      const normalizeFactor = j - i;\n      if (normalizeFactor !== 1) {\n        const normalizedHeight = dimension.height / normalizeFactor;\n        dimension.height = normalizedHeight;\n        dimension.endOffset = dimension.startOffset + dimension.height;\n        for (j = i + 1; j < i + normalizeFactor; j += 1) {\n          const startOffset = dimension.endOffset;\n          dimension = dimensionList[j];\n          dimension.startOffset = startOffset;\n          dimension.height = normalizedHeight;\n          dimension.endOffset = dimension.startOffset + dimension.height;\n        }\n        i = j - 1;\n      }\n    }\n  }\n};\n\nconst normalizeEditorDimensions = dimensionNormalizer('editorDimension');\nconst normalizePreviewDimensions = dimensionNormalizer('previewDimension');\nconst normalizeTocDimensions = dimensionNormalizer('tocDimension');\n\nexport default {\n  measureSectionDimensions(editorSvc) {\n    let editorSectionOffset = 0;\n    let previewSectionOffset = 0;\n    let tocSectionOffset = 0;\n    let sectionDesc = editorSvc.previewCtx.sectionDescList[0];\n    let nextSectionDesc;\n    let i = 1;\n    for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) {\n      nextSectionDesc = editorSvc.previewCtx.sectionDescList[i];\n\n      // Measure editor section\n      let newEditorSectionOffset = nextSectionDesc.editorElt\n        ? nextSectionDesc.editorElt.offsetTop\n        : editorSectionOffset;\n      newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset\n        ? newEditorSectionOffset\n        : editorSectionOffset;\n      sectionDesc.editorDimension = new SectionDimension(\n        editorSectionOffset,\n        newEditorSectionOffset,\n      );\n      editorSectionOffset = newEditorSectionOffset;\n\n      // Measure preview section\n      let newPreviewSectionOffset = nextSectionDesc.previewElt\n        ? nextSectionDesc.previewElt.offsetTop\n        : previewSectionOffset;\n      newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset\n        ? newPreviewSectionOffset\n        : previewSectionOffset;\n      sectionDesc.previewDimension = new SectionDimension(\n        previewSectionOffset,\n        newPreviewSectionOffset,\n      );\n      previewSectionOffset = newPreviewSectionOffset;\n\n      // Measure TOC section\n      let newTocSectionOffset = nextSectionDesc.tocElt\n        ? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2)\n        : tocSectionOffset;\n      newTocSectionOffset = newTocSectionOffset > tocSectionOffset\n        ? newTocSectionOffset\n        : tocSectionOffset;\n      sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset);\n      tocSectionOffset = newTocSectionOffset;\n\n      sectionDesc = nextSectionDesc;\n    }\n\n    // Last section\n    sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1];\n    if (sectionDesc) {\n      sectionDesc.editorDimension = new SectionDimension(\n        editorSectionOffset,\n        editorSvc.editorElt.scrollHeight,\n      );\n      sectionDesc.previewDimension = new SectionDimension(\n        previewSectionOffset,\n        editorSvc.previewElt.scrollHeight,\n      );\n      sectionDesc.tocDimension = new SectionDimension(\n        tocSectionOffset,\n        editorSvc.tocElt.scrollHeight,\n      );\n    }\n\n    normalizeEditorDimensions(editorSvc);\n    normalizePreviewDimensions(editorSvc);\n    normalizeTocDimensions(editorSvc);\n  },\n};\n"
  },
  {
    "path": "src/services/editorSvc.js",
    "content": "import Vue from 'vue';\nimport DiffMatchPatch from 'diff-match-patch';\nimport Prism from 'prismjs';\nimport markdownItPandocRenderer from 'markdown-it-pandoc-renderer';\nimport cledit from './editor/cledit';\nimport pagedown from '../libs/pagedown';\nimport htmlSanitizer from '../libs/htmlSanitizer';\nimport markdownConversionSvc from './markdownConversionSvc';\nimport markdownGrammarSvc from './markdownGrammarSvc';\nimport sectionUtils from './editor/sectionUtils';\nimport extensionSvc from './extensionSvc';\nimport editorSvcDiscussions from './editor/editorSvcDiscussions';\nimport editorSvcUtils from './editor/editorSvcUtils';\nimport utils from './utils';\nimport store from '../store';\n\nconst allowDebounce = (action, wait) => {\n  let timeoutId;\n  return (doDebounce = false, ...params) => {\n    clearTimeout(timeoutId);\n    if (doDebounce) {\n      timeoutId = setTimeout(() => action(...params), wait);\n    } else {\n      action(...params);\n    }\n  };\n};\n\nconst diffMatchPatch = new DiffMatchPatch();\nlet instantPreview = true;\nlet tokens;\n\nclass SectionDesc {\n  constructor(section, previewElt, tocElt, html) {\n    this.section = section;\n    this.editorElt = section.elt;\n    this.previewElt = previewElt;\n    this.tocElt = tocElt;\n    this.html = html;\n  }\n}\n\n// Use a vue instance as an event bus\nconst editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, {\n  // Elements\n  editorElt: null,\n  previewElt: null,\n  tocElt: null,\n  // Other objects\n  clEditor: null,\n  pagedownEditor: null,\n  options: null,\n  prismGrammars: null,\n  converter: null,\n  parsingCtx: null,\n  conversionCtx: null,\n  previewCtx: {\n    sectionDescList: [],\n  },\n  previewCtxMeasured: null,\n  previewCtxWithDiffs: null,\n  sectionList: null,\n  selectionRange: null,\n  previewSelectionRange: null,\n  previewSelectionStartOffset: null,\n\n  /**\n   * Initialize the Prism grammar with the options\n   */\n  initPrism() {\n    const options = {\n      ...this.options,\n      insideFences: markdownConversionSvc.defaultOptions.insideFences,\n    };\n    this.prismGrammars = markdownGrammarSvc.makeGrammars(options);\n  },\n\n  /**\n   * Initialize the markdown-it converter with the options\n   */\n  initConverter() {\n    this.converter = markdownConversionSvc.createConverter(this.options, true);\n  },\n\n  /**\n   * Initialize the cledit editor with markdown-it section parser and Prism highlighter\n   */\n  initClEditor() {\n    this.previewCtxMeasured = null;\n    editorSvc.$emit('previewCtxMeasured', null);\n    this.previewCtxWithDiffs = null;\n    editorSvc.$emit('previewCtxWithDiffs', null);\n    const options = {\n      sectionHighlighter: section => Prism\n        .highlight(section.text, this.prismGrammars[section.data]),\n      sectionParser: (text) => {\n        this.parsingCtx = markdownConversionSvc.parseSections(this.converter, text);\n        return this.parsingCtx.sections;\n      },\n      getCursorFocusRatio: () => {\n        if (store.getters['data/layoutSettings'].focusMode) {\n          return 1;\n        }\n        return 0.15;\n      },\n    };\n    this.initClEditorInternal(options);\n    this.restoreScrollPosition();\n  },\n\n  /**\n   * Finish the conversion initiated by the section parser\n   */\n  convert() {\n    this.conversionCtx = markdownConversionSvc.convert(this.parsingCtx, this.conversionCtx);\n    this.$emit('conversionCtx', this.conversionCtx);\n    ({ tokens } = this.parsingCtx.markdownState);\n  },\n\n  /**\n   * Refresh the preview with the result of `convert()`\n   */\n  async refreshPreview() {\n    const sectionDescList = [];\n    let sectionPreviewElt;\n    let sectionTocElt;\n    let sectionIdx = 0;\n    let sectionDescIdx = 0;\n    let insertBeforePreviewElt = this.previewElt.firstChild;\n    let insertBeforeTocElt = this.tocElt.firstChild;\n    let previewHtml = '';\n    let loadingImages = [];\n    this.conversionCtx.htmlSectionDiff.forEach((item) => {\n      for (let i = 0; i < item[1].length; i += 1) {\n        const section = this.conversionCtx.sectionList[sectionIdx];\n        if (item[0] === 0) {\n          let sectionDesc = this.previewCtx.sectionDescList[sectionDescIdx];\n          sectionDescIdx += 1;\n          if (sectionDesc.editorElt !== section.elt) {\n            // Force textToPreviewDiffs computation\n            sectionDesc = new SectionDesc(\n              section,\n              sectionDesc.previewElt,\n              sectionDesc.tocElt,\n              sectionDesc.html,\n            );\n          }\n          sectionDescList.push(sectionDesc);\n          previewHtml += sectionDesc.html;\n          sectionIdx += 1;\n          insertBeforePreviewElt = insertBeforePreviewElt.nextSibling;\n          insertBeforeTocElt = insertBeforeTocElt.nextSibling;\n        } else if (item[0] === -1) {\n          sectionDescIdx += 1;\n          sectionPreviewElt = insertBeforePreviewElt;\n          insertBeforePreviewElt = insertBeforePreviewElt.nextSibling;\n          this.previewElt.removeChild(sectionPreviewElt);\n          sectionTocElt = insertBeforeTocElt;\n          insertBeforeTocElt = insertBeforeTocElt.nextSibling;\n          this.tocElt.removeChild(sectionTocElt);\n        } else if (item[0] === 1) {\n          const html = htmlSanitizer.sanitizeHtml(this.conversionCtx.htmlSectionList[sectionIdx]);\n          sectionIdx += 1;\n\n          // Create preview section element\n          sectionPreviewElt = document.createElement('div');\n          sectionPreviewElt.className = 'cl-preview-section';\n          sectionPreviewElt.innerHTML = html;\n          if (insertBeforePreviewElt) {\n            this.previewElt.insertBefore(sectionPreviewElt, insertBeforePreviewElt);\n          } else {\n            this.previewElt.appendChild(sectionPreviewElt);\n          }\n          extensionSvc.sectionPreview(sectionPreviewElt, this.options, true);\n          loadingImages = [\n            ...loadingImages,\n            ...Array.prototype.slice.call(sectionPreviewElt.getElementsByTagName('img')),\n          ];\n\n          // Create TOC section element\n          sectionTocElt = document.createElement('div');\n          sectionTocElt.className = 'cl-toc-section';\n          const headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6');\n          if (headingElt) {\n            const clonedElt = headingElt.cloneNode(true);\n            clonedElt.removeAttribute('id');\n            sectionTocElt.appendChild(clonedElt);\n          }\n          if (insertBeforeTocElt) {\n            this.tocElt.insertBefore(sectionTocElt, insertBeforeTocElt);\n          } else {\n            this.tocElt.appendChild(sectionTocElt);\n          }\n\n          previewHtml += html;\n          sectionDescList.push(new SectionDesc(section, sectionPreviewElt, sectionTocElt, html));\n        }\n      }\n    });\n\n    this.tocElt.classList[\n      this.tocElt.querySelector('.cl-toc-section *') ? 'remove' : 'add'\n    ]('toc-tab--empty');\n\n    this.previewCtx = {\n      markdown: this.conversionCtx.text,\n      html: previewHtml.replace(/^\\s+|\\s+$/g, ''),\n      text: this.previewElt.textContent,\n      sectionDescList,\n    };\n    this.$emit('previewCtx', this.previewCtx);\n    this.makeTextToPreviewDiffs();\n\n    // Wait for images to load\n    const loadedPromises = loadingImages.map(imgElt => new Promise((resolve) => {\n      if (!imgElt.src) {\n        resolve();\n        return;\n      }\n      const img = new window.Image();\n      img.onload = resolve;\n      img.onerror = resolve;\n      img.src = imgElt.src;\n    }));\n    await Promise.all(loadedPromises);\n\n    // Debounce if sections have already been measured\n    this.measureSectionDimensions(!!this.previewCtxMeasured);\n  },\n\n  /**\n   * Measure the height of each section in editor, preview and toc.\n   */\n  measureSectionDimensions: allowDebounce((restoreScrollPosition = false, force = false) => {\n    if (force || editorSvc.previewCtx !== editorSvc.previewCtxMeasured) {\n      sectionUtils.measureSectionDimensions(editorSvc);\n      editorSvc.previewCtxMeasured = editorSvc.previewCtx;\n      if (restoreScrollPosition) {\n        editorSvc.restoreScrollPosition();\n      }\n      editorSvc.$emit('previewCtxMeasured', editorSvc.previewCtxMeasured);\n    }\n  }, 500),\n\n  /**\n   * Compute the diffs between editor's markdown and preview's html\n   * asynchronously unless there is only one section to compute.\n   */\n  makeTextToPreviewDiffs() {\n    if (editorSvc.previewCtx !== editorSvc.previewCtxWithDiffs) {\n      const makeOne = () => {\n        let hasOne = false;\n        const hasMore = editorSvc.previewCtx.sectionDescList\n          .some((sectionDesc) => {\n            if (!sectionDesc.textToPreviewDiffs) {\n              if (hasOne) {\n                return true;\n              }\n              if (!sectionDesc.previewText) {\n                sectionDesc.previewText = sectionDesc.previewElt.textContent;\n              }\n              sectionDesc.textToPreviewDiffs = diffMatchPatch.diff_main(\n                sectionDesc.section.text,\n                sectionDesc.previewText,\n              );\n              hasOne = true;\n            }\n            return false;\n          });\n        if (hasMore) {\n          setTimeout(() => makeOne(), 10);\n        } else {\n          editorSvc.previewCtxWithDiffs = editorSvc.previewCtx;\n          editorSvc.$emit('previewCtxWithDiffs', editorSvc.previewCtxWithDiffs);\n        }\n      };\n      makeOne();\n    }\n  },\n\n  /**\n   * Save editor selection/scroll state into the store.\n   */\n  saveContentState: allowDebounce(() => {\n    const scrollPosition = editorSvc.getScrollPosition() ||\n      store.getters['contentState/current'].scrollPosition;\n    store.dispatch('contentState/patchCurrent', {\n      selectionStart: editorSvc.clEditor.selectionMgr.selectionStart,\n      selectionEnd: editorSvc.clEditor.selectionMgr.selectionEnd,\n      scrollPosition,\n    });\n  }, 100),\n\n  /**\n   * Report selection from the preview to the editor.\n   */\n  saveSelection: allowDebounce(() => {\n    const selection = window.getSelection();\n    let range = selection.rangeCount && selection.getRangeAt(0);\n    if (range) {\n      if (\n        /* eslint-disable no-bitwise */\n        !(editorSvc.previewElt.compareDocumentPosition(range.startContainer) &\n          window.Node.DOCUMENT_POSITION_CONTAINED_BY) ||\n        !(editorSvc.previewElt.compareDocumentPosition(range.endContainer) &\n          window.Node.DOCUMENT_POSITION_CONTAINED_BY)\n        /* eslint-enable no-bitwise */\n      ) {\n        range = null;\n      }\n    }\n    if (editorSvc.previewSelectionRange !== range) {\n      let previewSelectionStartOffset;\n      let previewSelectionEndOffset;\n      if (range) {\n        const startRange = document.createRange();\n        startRange.setStart(editorSvc.previewElt, 0);\n        startRange.setEnd(range.startContainer, range.startOffset);\n        previewSelectionStartOffset = `${startRange}`.length;\n        previewSelectionEndOffset = previewSelectionStartOffset + `${range}`.length;\n        const editorStartOffset = editorSvc.getEditorOffset(previewSelectionStartOffset);\n        const editorEndOffset = editorSvc.getEditorOffset(previewSelectionEndOffset);\n        if (editorStartOffset != null && editorEndOffset != null) {\n          editorSvc.clEditor.selectionMgr.setSelectionStartEnd(\n            editorStartOffset,\n            editorEndOffset,\n          );\n        }\n      }\n      editorSvc.previewSelectionRange = range;\n      editorSvc.$emit('previewSelectionRange', editorSvc.previewSelectionRange);\n    }\n  }, 50),\n\n  /**\n   * Returns the pandoc AST generated from the file tokens and the converter options\n   */\n  getPandocAst() {\n    return tokens && markdownItPandocRenderer(tokens, this.converter.options);\n  },\n\n  /**\n   * Pass the elements to the store and initialize the editor.\n   */\n  init(editorElt, previewElt, tocElt) {\n    this.editorElt = editorElt;\n    this.previewElt = previewElt;\n    this.tocElt = tocElt;\n\n    this.createClEditor(editorElt);\n\n    this.clEditor.on('contentChanged', (content, diffs, sectionList) => {\n      this.parsingCtx = {\n        ...this.parsingCtx,\n        sectionList,\n      };\n    });\n    this.clEditor.undoMgr.on('undoStateChange', () => {\n      const canUndo = this.clEditor.undoMgr.canUndo();\n      if (canUndo !== store.state.layout.canUndo) {\n        store.commit('layout/setCanUndo', canUndo);\n      }\n      const canRedo = this.clEditor.undoMgr.canRedo();\n      if (canRedo !== store.state.layout.canRedo) {\n        store.commit('layout/setCanRedo', canRedo);\n      }\n    });\n    this.pagedownEditor = pagedown({\n      input: Object.create(this.clEditor),\n    });\n    this.pagedownEditor.run();\n    this.pagedownEditor.hooks.set('insertLinkDialog', (callback) => {\n      store.dispatch('modal/open', {\n        type: 'link',\n        callback,\n      });\n      return true;\n    });\n    this.pagedownEditor.hooks.set('insertImageDialog', (callback) => {\n      store.dispatch('modal/open', {\n        type: 'image',\n        callback,\n      });\n      return true;\n    });\n\n    this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));\n    this.previewElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));\n\n    const refreshPreview = allowDebounce(() => {\n      this.convert();\n      if (instantPreview) {\n        this.refreshPreview();\n        this.measureSectionDimensions(false, true);\n      } else {\n        setTimeout(() => this.refreshPreview(), 10);\n      }\n      instantPreview = false;\n    }, 25);\n\n    let newSectionList;\n    let newSelectionRange;\n    const onEditorChanged = allowDebounce(() => {\n      if (this.sectionList !== newSectionList) {\n        this.sectionList = newSectionList;\n        this.$emit('sectionList', this.sectionList);\n        refreshPreview(!instantPreview);\n      }\n      if (this.selectionRange !== newSelectionRange) {\n        this.selectionRange = newSelectionRange;\n        this.$emit('selectionRange', this.selectionRange);\n      }\n      this.saveContentState();\n    }, 10);\n\n    this.clEditor.selectionMgr.on('selectionChanged', (start, end, selectionRange) => {\n      newSelectionRange = selectionRange;\n      onEditorChanged(!instantPreview);\n    });\n\n    /* -----------------------------\n     * Inline images\n     */\n\n    const imgCache = Object.create(null);\n\n    const hashImgElt = imgElt => `${imgElt.src}:${imgElt.width || -1}:${imgElt.height || -1}`;\n\n    const addToImgCache = (imgElt) => {\n      const hash = hashImgElt(imgElt);\n      let entries = imgCache[hash];\n      if (!entries) {\n        entries = [];\n        imgCache[hash] = entries;\n      }\n      entries.push(imgElt);\n    };\n\n    const getFromImgCache = (imgEltsToCache) => {\n      const hash = hashImgElt(imgEltsToCache);\n      const entries = imgCache[hash];\n      if (!entries) {\n        return null;\n      }\n      let imgElt;\n      return entries\n        .some((entry) => {\n          if (this.editorElt.contains(entry)) {\n            return false;\n          }\n          imgElt = entry;\n          return true;\n        }) && imgElt;\n    };\n\n    const triggerImgCacheGc = cledit.Utils.debounce(() => {\n      Object.entries(imgCache).forEach(([src, entries]) => {\n        // Filter entries that are not attached to the DOM\n        const filteredEntries = entries.filter(imgElt => this.editorElt.contains(imgElt));\n        if (filteredEntries.length) {\n          imgCache[src] = filteredEntries;\n        } else {\n          delete imgCache[src];\n        }\n      });\n    }, 100);\n\n    let imgEltsToCache = [];\n    if (store.getters['data/computedSettings'].editor.inlineImages) {\n      this.clEditor.highlighter.on('sectionHighlighted', (section) => {\n        section.elt.getElementsByClassName('token img').cl_each((imgTokenElt) => {\n          const srcElt = imgTokenElt.querySelector('.token.cl-src');\n          if (srcElt) {\n            // Create an img element before the .img.token and wrap both elements\n            // into a .token.img-wrapper\n            const imgElt = document.createElement('img');\n            imgElt.style.display = 'none';\n            const uri = srcElt.textContent;\n            if (!/^unsafe/.test(htmlSanitizer.sanitizeUri(uri, true))) {\n              imgElt.onload = () => {\n                imgElt.style.display = '';\n              };\n              imgElt.src = uri;\n              // Take img size into account\n              const sizeElt = imgTokenElt.querySelector('.token.cl-size');\n              if (sizeElt) {\n                const match = sizeElt.textContent.match(/=(\\d*)x(\\d*)/);\n                if (match[1]) {\n                  imgElt.width = parseInt(match[1], 10);\n                }\n                if (match[2]) {\n                  imgElt.height = parseInt(match[2], 10);\n                }\n              }\n              imgEltsToCache.push(imgElt);\n            }\n            const imgTokenWrapper = document.createElement('span');\n            imgTokenWrapper.className = 'token img-wrapper';\n            imgTokenElt.parentNode.insertBefore(imgTokenWrapper, imgTokenElt);\n            imgTokenWrapper.appendChild(imgElt);\n            imgTokenWrapper.appendChild(imgTokenElt);\n          }\n        });\n      });\n    }\n\n    this.clEditor.highlighter.on('highlighted', () => {\n      imgEltsToCache.forEach((imgElt) => {\n        const cachedImgElt = getFromImgCache(imgElt);\n        if (cachedImgElt) {\n          // Found a previously loaded image that has just been released\n          imgElt.parentNode.replaceChild(cachedImgElt, imgElt);\n        } else {\n          addToImgCache(imgElt);\n        }\n      });\n      imgEltsToCache = [];\n      // Eject released images from cache\n      triggerImgCacheGc();\n    });\n\n    this.clEditor.on('contentChanged', (content, diffs, sectionList) => {\n      newSectionList = sectionList;\n      onEditorChanged(!instantPreview);\n    });\n\n    // clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2'))\n    // var previewElt = element[0].querySelector('.preview')\n    // clEditorSvc.isPreviewTop = previewElt.scrollTop < 10\n    // previewElt.addEventListener('scroll', function () {\n    //   var isPreviewTop = previewElt.scrollTop < 10\n    //   if (isPreviewTop !== clEditorSvc.isPreviewTop) {\n    //     clEditorSvc.isPreviewTop = isPreviewTop\n    //     scope.$apply()\n    //   }\n    // })\n\n    // Watch file content changes\n    let lastContentId = null;\n    let lastProperties;\n    store.watch(\n      () => store.getters['content/currentChangeTrigger'],\n      () => {\n        const content = store.getters['content/current'];\n        // Track ID changes\n        let initClEditor = false;\n        if (content.id !== lastContentId) {\n          instantPreview = true;\n          lastContentId = content.id;\n          initClEditor = true;\n        }\n        // Track properties changes\n        if (content.properties !== lastProperties) {\n          lastProperties = content.properties;\n          const options = extensionSvc.getOptions(store.getters['content/currentProperties']);\n          if (utils.serializeObject(options) !== utils.serializeObject(this.options)) {\n            this.options = options;\n            this.initPrism();\n            this.initConverter();\n            initClEditor = true;\n          }\n        }\n        if (initClEditor) {\n          this.initClEditor();\n        }\n        // Apply potential text and discussion changes\n        this.applyContent();\n      }, {\n        immediate: true,\n      },\n    );\n\n    // Disable editor if hidden or if no content is loaded\n    store.watch(\n      () => store.getters['content/isCurrentEditable'],\n      editable => this.clEditor.toggleEditable(!!editable), {\n        immediate: true,\n      },\n    );\n\n    store.watch(\n      () => utils.serializeObject(store.getters['layout/styles']),\n      () => this.measureSectionDimensions(false, true, true),\n    );\n\n    this.initHighlighters();\n    this.$emit('inited');\n  },\n});\n\nexport default editorSvc;\n"
  },
  {
    "path": "src/services/explorerSvc.js",
    "content": "import store from '../store';\nimport workspaceSvc from './workspaceSvc';\nimport badgeSvc from './badgeSvc';\n\nexport default {\n  newItem(isFolder = false) {\n    let parentId = store.getters['explorer/selectedNodeFolder'].item.id;\n    if (parentId === 'trash' // Not allowed to create new items in the trash\n      || (isFolder && parentId === 'temp') // Not allowed to create new folders in the temp folder\n    ) {\n      parentId = null;\n    }\n    store.dispatch('explorer/openNode', parentId);\n    store.commit('explorer/setNewItem', {\n      type: isFolder ? 'folder' : 'file',\n      parentId,\n    });\n  },\n  async deleteItem() {\n    const selectedNode = store.getters['explorer/selectedNode'];\n    if (selectedNode.isNil) {\n      return;\n    }\n\n    if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') {\n      try {\n        await store.dispatch('modal/open', 'trashDeletion');\n      } catch (e) {\n        // Cancel\n      }\n      return;\n    }\n\n    // See if we have a confirmation dialog to show\n    let moveToTrash = true;\n    try {\n      if (selectedNode.isTemp) {\n        await store.dispatch('modal/open', 'tempFolderDeletion');\n        moveToTrash = false;\n      } else if (selectedNode.item.parentId === 'temp') {\n        await store.dispatch('modal/open', {\n          type: 'tempFileDeletion',\n          item: selectedNode.item,\n        });\n        moveToTrash = false;\n      } else if (selectedNode.isFolder) {\n        await store.dispatch('modal/open', {\n          type: 'folderDeletion',\n          item: selectedNode.item,\n        });\n      }\n    } catch (e) {\n      return; // cancel\n    }\n\n    const deleteFile = (id) => {\n      if (moveToTrash) {\n        workspaceSvc.setOrPatchItem({\n          id,\n          parentId: 'trash',\n        });\n      } else {\n        workspaceSvc.deleteFile(id);\n      }\n    };\n\n    if (selectedNode === store.getters['explorer/selectedNode']) {\n      const currentFileId = store.getters['file/current'].id;\n      let doClose = selectedNode.item.id === currentFileId;\n      if (selectedNode.isFolder) {\n        const recursiveDelete = (folderNode) => {\n          folderNode.folders.forEach(recursiveDelete);\n          folderNode.files.forEach((fileNode) => {\n            doClose = doClose || fileNode.item.id === currentFileId;\n            deleteFile(fileNode.item.id);\n          });\n          store.commit('folder/deleteItem', folderNode.item.id);\n        };\n        recursiveDelete(selectedNode);\n        badgeSvc.addBadge('removeFolder');\n      } else {\n        deleteFile(selectedNode.item.id);\n        badgeSvc.addBadge('removeFile');\n      }\n      if (doClose) {\n        // Close the current file by opening the last opened, not deleted one\n        store.getters['data/lastOpenedIds'].some((id) => {\n          const file = store.state.file.itemsById[id];\n          if (file.parentId === 'trash') {\n            return false;\n          }\n          store.commit('file/setCurrentId', id);\n          return true;\n        });\n      }\n    }\n  },\n};\n"
  },
  {
    "path": "src/services/exportSvc.js",
    "content": "import FileSaver from 'file-saver';\nimport TemplateWorker from 'worker-loader!./templateWorker.js'; // eslint-disable-line\nimport localDbSvc from './localDbSvc';\nimport markdownConversionSvc from './markdownConversionSvc';\nimport extensionSvc from './extensionSvc';\nimport utils from './utils';\nimport store from '../store';\nimport htmlSanitizer from '../libs/htmlSanitizer';\n\nfunction groupHeadings(headings, level = 1) {\n  const result = [];\n  let currentItem;\n\n  function pushCurrentItem() {\n    if (currentItem) {\n      if (currentItem.children.length > 0) {\n        currentItem.children = groupHeadings(currentItem.children, level + 1);\n      }\n      result.push(currentItem);\n    }\n  }\n  headings.forEach((heading) => {\n    if (heading.level !== level) {\n      currentItem = currentItem || {\n        children: [],\n      };\n      currentItem.children.push(heading);\n    } else {\n      pushCurrentItem();\n      currentItem = heading;\n    }\n  });\n  pushCurrentItem();\n  return result;\n}\n\nconst containerElt = document.createElement('div');\ncontainerElt.className = 'hidden-rendering-container';\ndocument.body.appendChild(containerElt);\n\nexport default {\n  /**\n   * Apply the template to the file content\n   */\n  async applyTemplate(fileId, template = {\n    value: '{{{files.0.content.text}}}',\n    helpers: '',\n  }, pdf = false) {\n    const file = store.state.file.itemsById[fileId];\n    const content = await localDbSvc.loadItem(`${fileId}/content`);\n    const properties = utils.computeProperties(content.properties);\n    const options = extensionSvc.getOptions(properties);\n    const converter = markdownConversionSvc.createConverter(options, true);\n    const parsingCtx = markdownConversionSvc.parseSections(converter, content.text);\n    const conversionCtx = markdownConversionSvc.convert(parsingCtx);\n    const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join('');\n    containerElt.innerHTML = html;\n    extensionSvc.sectionPreview(containerElt, options);\n\n    // Unwrap tables\n    containerElt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => {\n      while (wrapperElt.firstChild) {\n        wrapperElt.parentNode.insertBefore(wrapperElt.firstChild, wrapperElt.nextSibling);\n      }\n      wrapperElt.parentNode.removeChild(wrapperElt);\n    });\n\n    // Make TOC\n    const headings = containerElt.querySelectorAll('h1,h2,h3,h4,h5,h6').cl_map(headingElt => ({\n      title: headingElt.textContent,\n      anchor: headingElt.id,\n      level: parseInt(headingElt.tagName.slice(1), 10),\n      children: [],\n    }));\n    const toc = groupHeadings(headings);\n    const view = {\n      pdf,\n      files: [{\n        name: file.name,\n        content: {\n          text: content.text,\n          properties,\n          yamlProperties: content.properties,\n          html: containerElt.innerHTML,\n          toc,\n        },\n      }],\n    };\n    containerElt.innerHTML = '';\n\n    // Run template conversion in a Worker to prevent attacks from helpers\n    const worker = new TemplateWorker();\n    return new Promise((resolve, reject) => {\n      const timeoutId = setTimeout(() => {\n        worker.terminate();\n        reject(new Error('Template generation timeout.'));\n      }, 10000);\n      worker.addEventListener('message', (e) => {\n        clearTimeout(timeoutId);\n        worker.terminate();\n        // e.data can contain unsafe data if helpers attempts to call postMessage\n        const [err, result] = e.data;\n        if (err) {\n          reject(new Error(`${err}`));\n        } else {\n          resolve(`${result}`);\n        }\n      });\n      worker.postMessage([template.value, view, template.helpers]);\n    });\n  },\n\n  /**\n   * Export a file to disk.\n   */\n  async exportToDisk(fileId, type, template) {\n    const file = store.state.file.itemsById[fileId];\n    const html = await this.applyTemplate(fileId, template);\n    const blob = new Blob([html], {\n      type: 'text/plain;charset=utf-8',\n    });\n    FileSaver.saveAs(blob, `${file.name}.${type}`);\n  },\n};\n"
  },
  {
    "path": "src/services/extensionSvc.js",
    "content": "const getOptionsListeners = [];\nconst initConverterListeners = [];\nconst sectionPreviewListeners = [];\n\nexport default {\n  onGetOptions(listener) {\n    getOptionsListeners.push(listener);\n  },\n\n  onInitConverter(priority, listener) {\n    initConverterListeners[priority] = listener;\n  },\n\n  onSectionPreview(listener) {\n    sectionPreviewListeners.push(listener);\n  },\n\n  getOptions(properties, isCurrentFile) {\n    return getOptionsListeners.reduce((options, listener) => {\n      listener(options, properties, isCurrentFile);\n      return options;\n    }, {});\n  },\n\n  initConverter(markdown, options) {\n    // Use forEach as it's a sparsed array\n    initConverterListeners.forEach((listener) => {\n      listener(markdown, options);\n    });\n  },\n\n  sectionPreview(elt, options, isEditor) {\n    sectionPreviewListeners.forEach((listener) => {\n      listener(elt, options, isEditor);\n    });\n  },\n};\n"
  },
  {
    "path": "src/services/gitWorkspaceSvc.js",
    "content": "import store from '../store';\nimport utils from '../services/utils';\n\nconst endsWith = (str, suffix) => str.slice(-suffix.length) === suffix;\n\nexport default {\n  shaByPath: Object.create(null),\n  makeChanges(tree) {\n    const workspacePath = store.getters['workspace/currentWorkspace'].path || '';\n\n    // Store all blobs sha\n    this.shaByPath = Object.create(null);\n    // Store interesting paths\n    const treeFolderMap = Object.create(null);\n    const treeFileMap = Object.create(null);\n    const treeDataMap = Object.create(null);\n    const treeSyncLocationMap = Object.create(null);\n    const treePublishLocationMap = Object.create(null);\n\n    tree.filter(({ type, path }) => type === 'blob' && path.indexOf(workspacePath) === 0)\n      .forEach((blobEntry) => {\n        // Make path relative\n        const path = blobEntry.path.slice(workspacePath.length);\n        // Collect blob sha\n        this.shaByPath[path] = blobEntry.sha;\n        if (path.indexOf('.stackedit-data/') === 0) {\n          treeDataMap[path] = true;\n        } else {\n          // Collect parents path\n          let parentPath = '';\n          path.split('/').slice(0, -1).forEach((folderName) => {\n            const folderPath = `${parentPath}${folderName}/`;\n            treeFolderMap[folderPath] = parentPath;\n            parentPath = folderPath;\n          });\n          // Collect file path\n          if (endsWith(path, '.md')) {\n            treeFileMap[path] = parentPath;\n          } else if (endsWith(path, '.sync')) {\n            treeSyncLocationMap[path] = true;\n          } else if (endsWith(path, '.publish')) {\n            treePublishLocationMap[path] = true;\n          }\n        }\n      });\n\n    // Collect changes\n    const changes = [];\n    const idsByPath = {};\n    const syncDataByPath = store.getters['data/syncDataById'];\n    const { itemIdsByGitPath } = store.getters;\n    const getIdFromPath = (path, isFile) => {\n      let itemId = idsByPath[path];\n      if (!itemId) {\n        const existingItemId = itemIdsByGitPath[path];\n        if (existingItemId\n          // Reuse a file ID only if it has already been synced\n          && (!isFile || syncDataByPath[path]\n          // Content may have already been synced\n          || syncDataByPath[`/${path}`])\n        ) {\n          itemId = existingItemId;\n        } else {\n          // Otherwise, make a new ID for a new item\n          itemId = utils.uid();\n        }\n        // If it's a file path, add the content path as well\n        if (isFile) {\n          idsByPath[`/${path}`] = `${itemId}/content`;\n        }\n        idsByPath[path] = itemId;\n      }\n      return itemId;\n    };\n\n    // Folder creations/updates\n    // Assume map entries are sorted from top to bottom\n    Object.entries(treeFolderMap).forEach(([path, parentPath]) => {\n      if (path === '.stackedit-trash/') {\n        idsByPath[path] = 'trash';\n      } else {\n        const item = utils.addItemHash({\n          id: getIdFromPath(path),\n          type: 'folder',\n          name: path.slice(parentPath.length, -1),\n          parentId: idsByPath[parentPath] || null,\n        });\n\n        const folderSyncData = syncDataByPath[path];\n        if (!folderSyncData || folderSyncData.hash !== item.hash) {\n          changes.push({\n            syncDataId: path,\n            item,\n            syncData: {\n              id: path,\n              type: item.type,\n              hash: item.hash,\n            },\n          });\n        }\n      }\n    });\n\n    // File/content creations/updates\n    Object.entries(treeFileMap).forEach(([path, parentPath]) => {\n      const fileId = getIdFromPath(path, true);\n      const contentPath = `/${path}`;\n      const contentId = idsByPath[contentPath];\n\n      // File creations/updates\n      const item = utils.addItemHash({\n        id: fileId,\n        type: 'file',\n        name: path.slice(parentPath.length, -'.md'.length),\n        parentId: idsByPath[parentPath] || null,\n      });\n\n      const fileSyncData = syncDataByPath[path];\n      if (!fileSyncData || fileSyncData.hash !== item.hash) {\n        changes.push({\n          syncDataId: path,\n          item,\n          syncData: {\n            id: path,\n            type: item.type,\n            hash: item.hash,\n          },\n        });\n      }\n\n      // Content creations/updates\n      const contentSyncData = syncDataByPath[contentPath];\n      if (!contentSyncData || contentSyncData.sha !== this.shaByPath[path]) {\n        const type = 'content';\n        // Use `/` as a prefix to get a unique syncData id\n        changes.push({\n          syncDataId: contentPath,\n          item: {\n            id: contentId,\n            type,\n            // Need a truthy value to force downloading the content\n            hash: 1,\n          },\n          syncData: {\n            id: contentPath,\n            type,\n            // Need a truthy value to force downloading the content\n            hash: 1,\n          },\n        });\n      }\n    });\n\n    // Data creations/updates\n    const syncDataByItemId = store.getters['data/syncDataByItemId'];\n    Object.keys(treeDataMap).forEach((path) => {\n      // Only template data are stored\n      const [, id] = path.match(/^\\.stackedit-data\\/(templates)\\.json$/) || [];\n      if (id) {\n        idsByPath[path] = id;\n        const syncData = syncDataByItemId[id];\n        if (!syncData || syncData.sha !== this.shaByPath[path]) {\n          const type = 'data';\n          changes.push({\n            syncDataId: path,\n            item: {\n              id,\n              type,\n              // Need a truthy value to force saving sync data\n              hash: 1,\n            },\n            syncData: {\n              id: path,\n              type,\n              // Need a truthy value to force downloading the content\n              hash: 1,\n            },\n          });\n        }\n      }\n    });\n\n    // Location creations/updates\n    [{\n      type: 'syncLocation',\n      map: treeSyncLocationMap,\n      pathMatcher: /^([\\s\\S]+)\\.([\\w-]+)\\.sync$/,\n    }, {\n      type: 'publishLocation',\n      map: treePublishLocationMap,\n      pathMatcher: /^([\\s\\S]+)\\.([\\w-]+)\\.publish$/,\n    }]\n      .forEach(({ type, map, pathMatcher }) => Object.keys(map).forEach((path) => {\n        const [, filePath, data] = path.match(pathMatcher) || [];\n        if (filePath) {\n          // If there is a corresponding md file in the tree\n          const fileId = idsByPath[`${filePath}.md`];\n          if (fileId) {\n            // Reuse existing ID or create a new one\n            const id = itemIdsByGitPath[path] || utils.uid();\n            idsByPath[path] = id;\n\n            const item = utils.addItemHash({\n              ...JSON.parse(utils.decodeBase64(data)),\n              id,\n              type,\n              fileId,\n            });\n\n            const locationSyncData = syncDataByPath[path];\n            if (!locationSyncData || locationSyncData.hash !== item.hash) {\n              changes.push({\n                syncDataId: path,\n                item,\n                syncData: {\n                  id: path,\n                  type: item.type,\n                  hash: item.hash,\n                },\n              });\n            }\n          }\n        }\n      }));\n\n    // Deletions\n    Object.keys(syncDataByPath).forEach((path) => {\n      if (!idsByPath[path]) {\n        changes.push({ syncDataId: path });\n      }\n    });\n\n    return changes;\n  },\n};\n"
  },
  {
    "path": "src/services/localDbSvc.js",
    "content": "import utils from './utils';\nimport store from '../store';\nimport welcomeFile from '../data/welcomeFile.md';\nimport workspaceSvc from './workspaceSvc';\nimport constants from '../data/constants';\n\nconst deleteMarkerMaxAge = 1000;\nconst dbVersion = 1;\nconst dbStoreName = 'objects';\nconst { silent } = utils.queryParams;\nconst resetApp = localStorage.getItem('resetStackEdit');\nif (resetApp) {\n  localStorage.removeItem('resetStackEdit');\n}\n\nclass Connection {\n  constructor(workspaceId = store.getters['workspace/currentWorkspace'].id) {\n    this.getTxCbs = [];\n\n    // Make the DB name\n    this.dbName = utils.getDbName(workspaceId);\n\n    // Init connection\n    const request = indexedDB.open(this.dbName, dbVersion);\n\n    request.onerror = () => {\n      throw new Error(\"Can't connect to IndexedDB.\");\n    };\n\n    request.onsuccess = (event) => {\n      this.db = event.target.result;\n      this.db.onversionchange = () => window.location.reload();\n\n      this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError));\n      this.getTxCbs = null;\n    };\n\n    request.onupgradeneeded = (event) => {\n      const eventDb = event.target.result;\n      const oldVersion = event.oldVersion || 0;\n\n      // We don't use 'break' in this switch statement,\n      // the fall-through behavior is what we want.\n      /* eslint-disable no-fallthrough */\n      switch (oldVersion) {\n        case 0: {\n          // Create store\n          const dbStore = eventDb.createObjectStore(dbStoreName, {\n            keyPath: 'id',\n          });\n          dbStore.createIndex('tx', 'tx', {\n            unique: false,\n          });\n        }\n        default:\n      }\n      /* eslint-enable no-fallthrough */\n    };\n  }\n\n  /**\n   * Create a transaction asynchronously.\n   */\n  createTx(onTx, onError) {\n    // If DB is not ready, keep callbacks for later\n    if (!this.db) {\n      return this.getTxCbs.push({ onTx, onError });\n    }\n\n    // Open transaction in read/write will prevent conflict with other tabs\n    const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite');\n    tx.onerror = onError;\n\n    return onTx(tx);\n  }\n}\n\nconst contentTypes = {\n  content: true,\n  contentState: true,\n  syncedContent: true,\n};\n\nconst hashMap = {};\nconstants.types.forEach((type) => {\n  hashMap[type] = Object.create(null);\n});\nconst lsHashMap = Object.create(null);\n\nconst localDbSvc = {\n  lastTx: 0,\n  hashMap,\n  connection: null,\n\n  /**\n   * Sync data items stored in the localStorage.\n   */\n  syncLocalStorage() {\n    constants.localStorageDataIds.forEach((id) => {\n      const key = `data/${id}`;\n\n      // Skip reloading the layoutSettings\n      if (id !== 'layoutSettings' || !lsHashMap[id]) {\n        try {\n          // Try to parse the item from the localStorage\n          const storedItem = JSON.parse(localStorage.getItem(key));\n          if (storedItem.hash && lsHashMap[id] !== storedItem.hash) {\n            // Item has changed, replace it in the store\n            store.commit('data/setItem', storedItem);\n            lsHashMap[id] = storedItem.hash;\n          }\n        } catch (e) {\n          // Ignore parsing issue\n        }\n      }\n\n      // Write item if different from stored one\n      const item = store.state.data.lsItemsById[id];\n      if (item && item.hash !== lsHashMap[id]) {\n        localStorage.setItem(key, JSON.stringify(item));\n        lsHashMap[id] = item.hash;\n      }\n    });\n  },\n\n  /**\n   * Return a promise that will be resolved once the synchronization between the store and the\n   * localDb will be finished. Effectively, open a transaction, then read and apply all changes\n   * from the DB since the previous transaction, then write all the changes from the store.\n   */\n  async sync() {\n    return new Promise((resolve, reject) => {\n      // Create the DB transaction\n      this.connection.createTx((tx) => {\n        const { lastTx } = this;\n\n        // Look for DB changes and apply them to the store\n        this.readAll(tx, (storeItemMap) => {\n          // Sanitize the workspace if changes have been applied\n          if (lastTx !== this.lastTx) {\n            workspaceSvc.sanitizeWorkspace();\n          }\n\n          // Persist all the store changes into the DB\n          this.writeAll(storeItemMap, tx);\n          // Sync the localStorage\n          this.syncLocalStorage();\n          // Done\n          resolve();\n        });\n      }, () => reject(new Error('Local DB access error.')));\n    });\n  },\n\n  /**\n   * Read and apply all changes from the DB since previous transaction.\n   */\n  readAll(tx, cb) {\n    let { lastTx } = this;\n    const dbStore = tx.objectStore(dbStoreName);\n    const index = dbStore.index('tx');\n    const range = IDBKeyRange.lowerBound(this.lastTx, true);\n    const changes = [];\n    index.openCursor(range).onsuccess = (event) => {\n      const cursor = event.target.result;\n      if (cursor) {\n        const item = cursor.value;\n        if (item.tx > lastTx) {\n          lastTx = item.tx;\n          if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) {\n            // We may have missed some delete markers\n            window.location.reload();\n            return;\n          }\n        }\n        // Collect change\n        changes.push(item);\n        cursor.continue();\n        return;\n      }\n\n      // Read the collected changes\n      const storeItemMap = { ...store.getters.allItemsById };\n      changes.forEach((item) => {\n        this.readDbItem(item, storeItemMap);\n        // If item is an old delete marker, remove it from the DB\n        if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) {\n          dbStore.delete(item.id);\n        }\n      });\n\n      this.lastTx = lastTx;\n      cb(storeItemMap);\n    };\n  },\n\n  /**\n   * Write all changes from the store since previous transaction.\n   */\n  writeAll(storeItemMap, tx) {\n    if (silent) {\n      // Skip writing to DB in silent mode\n      return;\n    }\n    const dbStore = tx.objectStore(dbStoreName);\n    const incrementedTx = this.lastTx + 1;\n\n    // Remove deleted store items\n    Object.keys(this.hashMap).forEach((type) => {\n      // Remove this type only if file is deleted\n      let checker = cb => id => !storeItemMap[id] && cb(id);\n      if (contentTypes[type]) {\n        // For content types, remove item only if file is deleted\n        checker = cb => (id) => {\n          if (!storeItemMap[id]) {\n            const [fileId] = id.split('/');\n            if (!store.state.file.itemsById[fileId]) {\n              cb(id);\n            }\n          }\n        };\n      }\n      Object.keys(this.hashMap[type]).forEach(checker((id) => {\n        // Put a delete marker to notify other tabs\n        dbStore.put({\n          id,\n          type,\n          tx: incrementedTx,\n        });\n        delete this.hashMap[type][id];\n        this.lastTx = incrementedTx;\n      }));\n    });\n\n    // Put changes\n    Object.entries(storeItemMap).forEach(([, storeItem]) => {\n      // Store object has changed\n      if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) {\n        const item = {\n          ...storeItem,\n          tx: incrementedTx,\n        };\n        dbStore.put(item);\n        this.hashMap[item.type][item.id] = item.hash;\n        this.lastTx = incrementedTx;\n      }\n    });\n  },\n\n  /**\n   * Read and apply one DB change.\n   */\n  readDbItem(dbItem, storeItemMap) {\n    const storeItem = storeItemMap[dbItem.id];\n    if (!dbItem.hash) {\n      // DB item is a delete marker\n      delete this.hashMap[dbItem.type][dbItem.id];\n      if (storeItem) {\n        // Remove item from the store\n        store.commit(`${storeItem.type}/deleteItem`, storeItem.id);\n        delete storeItemMap[storeItem.id];\n      }\n    } else if (this.hashMap[dbItem.type][dbItem.id] !== dbItem.hash) {\n      // DB item is different from the corresponding store item\n      this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;\n      // Update content only if it exists in the store\n      if (storeItem || !contentTypes[dbItem.type]) {\n        // Put item in the store\n        dbItem.tx = undefined;\n        store.commit(`${dbItem.type}/setItem`, dbItem);\n        storeItemMap[dbItem.id] = dbItem;\n      }\n    }\n  },\n\n  /**\n   * Retrieve an item from the DB and put it in the store.\n   */\n  async loadItem(id) {\n    // Check if item is in the store\n    const itemInStore = store.getters.allItemsById[id];\n    if (itemInStore) {\n      // Use deepCopy to freeze item\n      return Promise.resolve(itemInStore);\n    }\n    return new Promise((resolve, reject) => {\n      // Get the item from DB\n      const onError = () => reject(new Error('Data not available.'));\n      this.connection.createTx((tx) => {\n        const dbStore = tx.objectStore(dbStoreName);\n        const request = dbStore.get(id);\n        request.onsuccess = () => {\n          const dbItem = request.result;\n          if (!dbItem || !dbItem.hash) {\n            onError();\n          } else {\n            this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;\n            // Put item in the store\n            dbItem.tx = undefined;\n            store.commit(`${dbItem.type}/setItem`, dbItem);\n            resolve(dbItem);\n          }\n        };\n      }, () => onError());\n    });\n  },\n\n  /**\n   * Unload from the store contents that haven't been opened recently\n   */\n  async unloadContents() {\n    await this.sync();\n    // Keep only last opened files in memory\n    const lastOpenedFileIdSet = new Set(store.getters['data/lastOpenedIds']);\n    Object.keys(contentTypes).forEach((type) => {\n      store.getters[`${type}/items`].forEach((item) => {\n        const [fileId] = item.id.split('/');\n        if (!lastOpenedFileIdSet.has(fileId)) {\n          // Remove item from the store\n          store.commit(`${type}/deleteItem`, item.id);\n        }\n      });\n    });\n  },\n\n  /**\n   * Create the connection and start syncing.\n   */\n  async init() {\n    // Reset the app if the reset flag was passed\n    if (resetApp) {\n      await Promise.all(Object.keys(store.getters['workspace/workspacesById'])\n        .map(workspaceId => workspaceSvc.removeWorkspace(workspaceId)));\n      constants.localStorageDataIds.forEach((id) => {\n        // Clean data stored in localStorage\n        localStorage.removeItem(`data/${id}`);\n      });\n      throw new Error('RELOAD');\n    }\n\n    // Create the connection\n    this.connection = new Connection();\n\n    // Load the DB\n    await localDbSvc.sync();\n\n    // Watch workspace deletions and persist them as soon as possible\n    // to make the changes available to reloading workspace tabs.\n    store.watch(\n      () => store.getters['data/workspaces'],\n      () => this.syncLocalStorage(),\n    );\n\n    // Save welcome file content hash if not done already\n    const hash = utils.hash(welcomeFile);\n    const { welcomeFileHashes } = store.getters['data/localSettings'];\n    if (!welcomeFileHashes[hash]) {\n      store.dispatch('data/patchLocalSettings', {\n        welcomeFileHashes: {\n          ...welcomeFileHashes,\n          [hash]: 1,\n        },\n      });\n    }\n\n    // If app was last opened 7 days ago and synchronization is off\n    if (!store.getters['workspace/syncToken'] &&\n      (store.state.workspace.lastFocus + constants.cleanTrashAfter < Date.now())\n    ) {\n      // Clean files\n      store.getters['file/items']\n        .filter(file => file.parentId === 'trash') // If file is in the trash\n        .forEach(file => workspaceSvc.deleteFile(file.id));\n    }\n\n    // Sync local DB periodically\n    utils.setInterval(() => localDbSvc.sync(), 1000);\n\n    // watch current file changing\n    store.watch(\n      () => store.getters['file/current'].id,\n      async () => {\n        // See if currentFile is real, ie it has an ID\n        const currentFile = store.getters['file/current'];\n        // If current file has no ID, get the most recent file\n        if (!currentFile.id) {\n          const recentFile = store.getters['file/lastOpened'];\n          // Set it as the current file\n          if (recentFile.id) {\n            store.commit('file/setCurrentId', recentFile.id);\n          } else {\n            // If still no ID, create a new file\n            const newFile = await workspaceSvc.createFile({\n              name: 'Welcome file',\n              text: welcomeFile,\n            }, true);\n            // Set it as the current file\n            store.commit('file/setCurrentId', newFile.id);\n          }\n        } else {\n          try {\n            // Load contentState from DB\n            await localDbSvc.loadContentState(currentFile.id);\n            // Load syncedContent from DB\n            await localDbSvc.loadSyncedContent(currentFile.id);\n            // Load content from DB\n            try {\n              await localDbSvc.loadItem(`${currentFile.id}/content`);\n            } catch (err) {\n              // Failure (content is not available), go back to previous file\n              const lastOpenedFile = store.getters['file/lastOpened'];\n              store.commit('file/setCurrentId', lastOpenedFile.id);\n              throw err;\n            }\n            // Set last opened file\n            store.dispatch('data/setLastOpenedId', currentFile.id);\n            // Cancel new discussion and open the gutter if file contains discussions\n            store.commit(\n              'discussion/setCurrentDiscussionId',\n              store.getters['discussion/nextDiscussionId'],\n            );\n          } catch (err) {\n            console.error(err); // eslint-disable-line no-console\n            store.dispatch('notification/error', err);\n          }\n        }\n      },\n      { immediate: true },\n    );\n  },\n\n  getWorkspaceItems(workspaceId, onItem, onFinish = () => {}) {\n    const connection = new Connection(workspaceId);\n    connection.createTx((tx) => {\n      const dbStore = tx.objectStore(dbStoreName);\n      const index = dbStore.index('tx');\n      index.openCursor().onsuccess = (event) => {\n        const cursor = event.target.result;\n        if (cursor) {\n          onItem(cursor.value);\n          cursor.continue();\n        } else {\n          connection.db.close();\n          onFinish();\n        }\n      };\n    });\n\n    // Return a cancel function\n    return () => connection.db.close();\n  },\n};\n\nconst loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)\n  // Item does not exist, create it\n  .catch(() => store.commit(`${type}/setItem`, {\n    id: `${fileId}/${type}`,\n  }));\nlocalDbSvc.loadSyncedContent = loader('syncedContent');\nlocalDbSvc.loadContentState = loader('contentState');\n\nexport default localDbSvc;\n"
  },
  {
    "path": "src/services/markdownConversionSvc.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport Prism from 'prismjs';\nimport MarkdownIt from 'markdown-it';\nimport markdownGrammarSvc from './markdownGrammarSvc';\nimport extensionSvc from './extensionSvc';\nimport utils from './utils';\n\nconst htmlSectionMarker = '\\uF111\\uF222\\uF333\\uF444';\nconst diffMatchPatch = new DiffMatchPatch();\n\n// Create aliases for syntax highlighting\nconst languageAliases = ({\n  js: 'javascript',\n  json: 'javascript',\n  html: 'markup',\n  svg: 'markup',\n  xml: 'markup',\n  py: 'python',\n  rb: 'ruby',\n  yml: 'yaml',\n  ps1: 'powershell',\n  psm1: 'powershell',\n});\nObject.entries(languageAliases).forEach(([alias, language]) => {\n  Prism.languages[alias] = Prism.languages[language];\n});\n\n// Add programming language parsing capability to markdown fences\nconst insideFences = {};\nObject.entries(Prism.languages).forEach(([name, language]) => {\n  if (Prism.util.type(language) === 'Object') {\n    insideFences[`language-${name}`] = {\n      pattern: new RegExp(`(\\`\\`\\`|~~~)${name}\\\\W[\\\\s\\\\S]*`),\n      inside: {\n        'cl cl-pre': /(```|~~~).*/,\n        rest: language,\n      },\n    };\n  }\n});\n\n// Disable spell checking in specific tokens\nconst noSpellcheckTokens = Object.create(null);\n[\n  'code',\n  'pre',\n  'pre gfm',\n  'math block',\n  'math inline',\n  'math expr block',\n  'math expr inline',\n  'latex block',\n]\n  .forEach((key) => {\n    noSpellcheckTokens[key] = true;\n  });\nPrism.hooks.add('wrap', (env) => {\n  if (noSpellcheckTokens[env.type]) {\n    env.attributes.spellcheck = 'false';\n  }\n});\n\nfunction createFlagMap(arr) {\n  return arr.reduce((map, type) => ({ ...map, [type]: true }), {});\n}\nconst startSectionBlockTypeMap = createFlagMap([\n  'paragraph_open',\n  'blockquote_open',\n  'heading_open',\n  'code',\n  'fence',\n  'table_open',\n  'html_block',\n  'bullet_list_open',\n  'ordered_list_open',\n  'hr',\n  'dl_open',\n]);\nconst listBlockTypeMap = createFlagMap([\n  'bullet_list_open',\n  'ordered_list_open',\n]);\nconst blockquoteBlockTypeMap = createFlagMap([\n  'blockquote_open',\n]);\nconst tableBlockTypeMap = createFlagMap([\n  'table_open',\n]);\nconst deflistBlockTypeMap = createFlagMap([\n  'dl_open',\n]);\n\nfunction hashArray(arr, valueHash, valueArray) {\n  const hash = [];\n  arr.forEach((str) => {\n    let strHash = valueHash[str];\n    if (strHash === undefined) {\n      strHash = valueArray.length;\n      valueArray.push(str);\n      valueHash[str] = strHash;\n    }\n    hash.push(strHash);\n  });\n  return String.fromCharCode.apply(null, hash);\n}\n\nexport default {\n  defaultOptions: null,\n  defaultConverter: null,\n  defaultPrismGrammars: null,\n\n  init() {\n    const defaultProperties = { extensions: utils.computedPresets.default };\n\n    // Default options for the markdown converter and the grammar\n    this.defaultOptions = {\n      ...extensionSvc.getOptions(defaultProperties),\n      insideFences,\n    };\n\n    this.defaultConverter = this.createConverter(this.defaultOptions);\n    this.defaultPrismGrammars = markdownGrammarSvc.makeGrammars(this.defaultOptions);\n  },\n\n  /**\n   * Creates a converter and init it with extensions.\n   * @returns {Object} A converter.\n   */\n  createConverter(options) {\n    // Let the listeners add the rules\n    const converter = new MarkdownIt('zero');\n    converter.core.ruler.enable([], true);\n    converter.block.ruler.enable([], true);\n    converter.inline.ruler.enable([], true);\n    extensionSvc.initConverter(converter, options);\n    Object.keys(startSectionBlockTypeMap).forEach((type) => {\n      const rule = converter.renderer.rules[type] || converter.renderer.renderToken;\n      converter.renderer.rules[type] = (tokens, idx, opts, env, self) => {\n        if (tokens[idx].sectionDelimiter) {\n          // Add section delimiter\n          return htmlSectionMarker + rule.call(converter.renderer, tokens, idx, opts, env, self);\n        }\n        return rule.call(converter.renderer, tokens, idx, opts, env, self);\n      };\n    });\n    return converter;\n  },\n\n  /**\n   * Parse markdown sections by passing the 2 first block rules of the markdown-it converter.\n   * @param {Object} converter The markdown-it converter.\n   * @param {String} text The text to be parsed.\n   * @returns {Object} A parsing context to be passed to `convert`.\n   */\n  parseSections(converter, text) {\n    const markdownState = new converter.core.State(text, converter, {});\n    const markdownCoreRules = converter.core.ruler.getRules('');\n    markdownCoreRules[0](markdownState); // Pass the normalize rule\n    markdownCoreRules[1](markdownState); // Pass the block rule\n    const lines = text.split('\\n');\n    if (!lines[lines.length - 1]) {\n      // In cledit, last char is always '\\n'.\n      // Remove it as one will be added by addSection\n      lines.pop();\n    }\n    const parsingCtx = {\n      text,\n      sections: [],\n      converter,\n      markdownState,\n      markdownCoreRules,\n    };\n    let data = 'main';\n    let i = 0;\n\n    function addSection(maxLine) {\n      const section = {\n        text: '',\n        data,\n      };\n      for (; i < maxLine; i += 1) {\n        section.text += `${lines[i]}\\n`;\n      }\n      if (section) {\n        parsingCtx.sections.push(section);\n      }\n    }\n    markdownState.tokens.forEach((token, index) => {\n      // index === 0 means there are empty lines at the begining of the file\n      if (token.level === 0 && startSectionBlockTypeMap[token.type] === true) {\n        if (index > 0) {\n          token.sectionDelimiter = true;\n          addSection(token.map[0]);\n        }\n        if (listBlockTypeMap[token.type] === true) {\n          data = 'list';\n        } else if (blockquoteBlockTypeMap[token.type] === true) {\n          data = 'blockquote';\n        } else if (tableBlockTypeMap[token.type] === true) {\n          data = 'table';\n        } else if (deflistBlockTypeMap[token.type] === true) {\n          data = 'deflist';\n        } else {\n          data = 'main';\n        }\n      }\n    });\n    addSection(lines.length);\n    return parsingCtx;\n  },\n\n  /**\n   * Convert markdown sections previously parsed with `parseSections`.\n   * @param {Object} parsingCtx The parsing context returned by `parseSections`.\n   * @param {Object} previousConversionCtx The conversion context returned by a previous call\n   * to `convert`, in order to calculate the `htmlSectionDiff` of the returned conversion context.\n   * @returns {Object} A conversion context.\n   */\n  convert(parsingCtx, previousConversionCtx) {\n    // This function can be called twice without editor modification\n    // so prevent from converting it again.\n    if (!parsingCtx.markdownState.isConverted) {\n      // Skip 2 first rules previously passed in parseSections\n      parsingCtx.markdownCoreRules.slice(2).forEach(rule => rule(parsingCtx.markdownState));\n      parsingCtx.markdownState.isConverted = true;\n    }\n    const { tokens } = parsingCtx.markdownState;\n    const html = parsingCtx.converter.renderer.render(\n      tokens,\n      parsingCtx.converter.options,\n      parsingCtx.markdownState.env,\n    );\n    const htmlSectionList = html.split(htmlSectionMarker);\n    if (htmlSectionList[0] === '') {\n      htmlSectionList.shift();\n    }\n    const valueHash = Object.create(null);\n    const valueArray = [];\n    const newSectionHash = hashArray(htmlSectionList, valueHash, valueArray);\n    let htmlSectionDiff;\n    if (previousConversionCtx) {\n      const oldSectionHash = hashArray(\n        previousConversionCtx.htmlSectionList,\n        valueHash,\n        valueArray,\n      );\n      htmlSectionDiff = diffMatchPatch.diff_main(oldSectionHash, newSectionHash, false);\n    } else {\n      htmlSectionDiff = [\n        [1, newSectionHash],\n      ];\n    }\n    return {\n      text: parsingCtx.text,\n      sectionList: parsingCtx.sectionList,\n      htmlSectionList,\n      htmlSectionDiff,\n    };\n  },\n\n  /**\n   * Helper to highlight arbitrary markdown\n   * @param {Object} markdown The markdown content to highlight.\n   * @param {Object} converter An optional converter.\n   * @param {Object} grammars Optional grammars.\n   * @returns {Object} The highlighted markdown in HTML format.\n   */\n  highlight(markdown, converter = this.defaultConverter, grammars = this.defaultPrismGrammars) {\n    const parsingCtx = this.parseSections(converter, markdown);\n    return parsingCtx.sections\n      .map(section => Prism.highlight(section.text, grammars[section.data])).join('');\n  },\n};\n"
  },
  {
    "path": "src/services/markdownGrammarSvc.js",
    "content": "const charInsideUrl = '(&|[-A-Z0-9+@#/%?=~_|[\\\\]()!:,.;])';\nconst charEndingUrl = '(&|[-A-Z0-9+@#/%=~_|[\\\\])])';\nconst urlPattern = new RegExp(`(https?|ftp)(://${charInsideUrl}*${charEndingUrl})(?=$|\\\\W)`, 'gi');\nconst emailPattern = /(?:mailto:)?([-.\\w]+@[-a-z0-9]+(\\.[-a-z0-9]+)*\\.[a-z]+)/gi;\n\nconst markup = {\n  comment: /<!--[\\w\\W]*?-->/g,\n  tag: {\n    pattern: /<\\/?[\\w:-]+\\s*(?:\\s+[\\w:-]+(?:=(?:(\"|')(\\\\?[\\w\\W])*?\\1|[^\\s'\">=]+))?\\s*)*\\/?>/gi,\n    inside: {\n      tag: {\n        pattern: /^<\\/?[\\w:-]+/i,\n        inside: {\n          punctuation: /^<\\/?/,\n          namespace: /^[\\w-]+?:/,\n        },\n      },\n      'attr-value': {\n        pattern: /=(?:('|\")[\\w\\W]*?(\\1)|[^\\s>]+)/gi,\n        inside: {\n          punctuation: /=|>|\"/g,\n        },\n      },\n      punctuation: /\\/?>/g,\n      'attr-name': {\n        pattern: /[\\w:-]+/g,\n        inside: {\n          namespace: /^[\\w-]+?:/,\n        },\n      },\n    },\n  },\n  entity: /&#?[\\da-z]{1,8};/gi,\n};\n\nconst latex = {\n  // A tex command e.g. \\foo\n  keyword: /\\\\(?:[^a-zA-Z]|[a-zA-Z]+)/g,\n  // Curly and square braces\n  lparen: /[[({]/g,\n  // Curly and square braces\n  rparen: /[\\])}]/g,\n  // A comment. Tex comments start with % and go to\n  // the end of the line\n  comment: /%.*/g,\n};\n\nexport default {\n  makeGrammars(options) {\n    const grammars = {\n      main: {},\n      list: {},\n      blockquote: {},\n      table: {},\n      deflist: {},\n    };\n\n    grammars.deflist.deflist = {\n      pattern: new RegExp(\n        [\n          '^ {0,3}\\\\S.*\\\\n', // Description line\n          '(?:[ \\\\t]*\\\\n)?', // Optional empty line\n          '(?:',\n          '[ \\\\t]*:[ \\\\t].*\\\\n', // Colon line\n          '(?:',\n          '(?:',\n          '.*\\\\S.*\\\\n', // Non-empty line\n          '|',\n          '[ \\\\t]*\\\\n(?! ?\\\\S)', // Or empty line not followed by unindented line\n          ')',\n          ')*',\n          '(?:[ \\\\t]*\\\\n)*', // Empty lines\n          ')+',\n        ].join(''),\n        'm',\n      ),\n      inside: {\n        term: /^.+/,\n        cl: /^[ \\t]*:[ \\t]/gm,\n      },\n    };\n\n    const insideFences = options.insideFences || {};\n    insideFences['cl cl-pre'] = /```|~~~/;\n    if (options.fence) {\n      grammars.main['pre gfm'] = {\n        pattern: /^(```|~~~)[\\s\\S]*?\\n\\1 *$/gm,\n        inside: insideFences,\n      };\n      grammars.list['pre gfm'] = {\n        pattern: /^(?: {4}|\\t)(```|~~~)[\\s\\S]*?\\n(?: {4}|\\t)\\1\\s*$/gm,\n        inside: insideFences,\n      };\n      grammars.deflist.deflist.inside['pre gfm'] = grammars.list['pre gfm'];\n    }\n\n    grammars.main['h1 alt'] = {\n      pattern: /^.+\\n=+[ \\t]*$/gm,\n      inside: {\n        'cl cl-hash': /=+[ \\t]*$/,\n      },\n    };\n    grammars.main['h2 alt'] = {\n      pattern: /^.+\\n-+[ \\t]*$/gm,\n      inside: {\n        'cl cl-hash': /-+[ \\t]*$/,\n      },\n    };\n    for (let i = 6; i >= 1; i -= 1) {\n      grammars.main[`h${i}`] = {\n        pattern: new RegExp(`^#{${i}}[ \\t].+$`, 'gm'),\n        inside: {\n          'cl cl-hash': new RegExp(`^#{${i}}`),\n        },\n      };\n    }\n\n    const list = /^[ \\t]*([*+-]|\\d+\\.)[ \\t]/gm;\n    const blockquote = {\n      pattern: /^\\s*>.*(?:\\n[ \\t]*\\S.*)*/gm,\n      inside: {\n        'cl cl-gt': /^\\s*>/gm,\n        'cl cl-li': list,\n      },\n    };\n    grammars.list.blockquote = blockquote;\n    grammars.blockquote.blockquote = blockquote;\n    grammars.deflist.deflist.inside.blockquote = blockquote;\n    grammars.list['cl cl-li'] = list;\n    grammars.blockquote['cl cl-li'] = list;\n    grammars.deflist.deflist.inside['cl cl-li'] = list;\n\n    grammars.table.table = {\n      pattern: new RegExp(\n        [\n          '^\\\\s*\\\\S.*[|].*\\\\n', // Header Row\n          '[-| :]+\\\\n', // Separator\n          '(?:.*[|].*\\\\n?)*', // Table rows\n          '$',\n        ].join(''),\n        'gm',\n      ),\n      inside: {\n        'cl cl-title-separator': /^[-| :]+$/gm,\n        'cl cl-pipe': /[|]/gm,\n      },\n    };\n\n    grammars.main.hr = {\n      pattern: /^ {0,3}([*\\-_] *){3,}$/gm,\n    };\n\n    if (options.tasklist) {\n      grammars.list.task = {\n        pattern: /^\\[[ xX]\\] /,\n        inside: {\n          cl: /[[\\]]/,\n          strong: /[xX]/,\n        },\n      };\n    }\n\n    const defs = {};\n    if (options.footnote) {\n      defs.fndef = {\n        pattern: /^ {0,3}\\[\\^.*?\\]:.*$/gm,\n        inside: {\n          'ref-id': {\n            pattern: /^ {0,3}\\[\\^.*?\\]/,\n            inside: {\n              cl: /(\\[\\^|\\])/,\n            },\n          },\n        },\n      };\n    }\n    if (options.abbr) {\n      defs.abbrdef = {\n        pattern: /^ {0,3}\\*\\[.*?\\]:.*$/gm,\n        inside: {\n          'abbr-id': {\n            pattern: /^ {0,3}\\*\\[.*?\\]/,\n            inside: {\n              cl: /(\\*\\[|\\])/,\n            },\n          },\n        },\n      };\n    }\n    defs.linkdef = {\n      pattern: /^ {0,3}\\[.*?\\]:.*$/gm,\n      inside: {\n        'link-id': {\n          pattern: /^ {0,3}\\[.*?\\]/,\n          inside: {\n            cl: /[[\\]]/,\n          },\n        },\n        url: urlPattern,\n      },\n    };\n\n    Object.entries(defs).forEach(([name, def]) => {\n      grammars.main[name] = def;\n      grammars.list[name] = def;\n      grammars.blockquote[name] = def;\n      grammars.table[name] = def;\n      grammars.deflist[name] = def;\n    });\n\n    grammars.main.pre = {\n      pattern: /^\\s*\\n(?: {4}|\\t).*\\S.*\\n((?: {4}|\\t).*\\n)*/gm,\n    };\n\n    const rest = {};\n    rest.code = {\n      pattern: /(`+)[\\s\\S]*?\\1/g,\n      inside: {\n        'cl cl-code': /`/,\n      },\n    };\n    if (options.math) {\n      rest['math block'] = {\n        pattern: /\\\\\\\\\\[[\\s\\S]*?\\\\\\\\\\]/g,\n        inside: {\n          'cl cl-bracket-start': /^\\\\\\\\\\[/,\n          'cl cl-bracket-end': /\\\\\\\\\\]$/,\n          rest: latex,\n        },\n      };\n      rest['math inline'] = {\n        pattern: /\\\\\\\\\\([\\s\\S]*?\\\\\\\\\\)/g,\n        inside: {\n          'cl cl-bracket-start': /^\\\\\\\\\\(/,\n          'cl cl-bracket-end': /\\\\\\\\\\)$/,\n          rest: latex,\n        },\n      };\n      rest['math expr block'] = {\n        pattern: /(\\$\\$)[\\s\\S]*?\\1/g,\n        inside: {\n          'cl cl-bracket-start': /^\\$\\$/,\n          'cl cl-bracket-end': /\\$\\$$/,\n          rest: latex,\n        },\n      };\n      rest['math expr inline'] = {\n        pattern: /\\$(?!\\s)[\\s\\S]*?\\S\\$(?!\\d)/g,\n        inside: {\n          'cl cl-bracket-start': /^\\$/,\n          'cl cl-bracket-end': /\\$$/,\n          rest: latex,\n        },\n      };\n    }\n    if (options.footnote) {\n      rest.inlinefn = {\n        pattern: /\\^\\[.+?\\]/g,\n        inside: {\n          cl: /(\\^\\[|\\])/,\n        },\n      };\n      rest.fn = {\n        pattern: /\\[\\^.+?\\]/g,\n        inside: {\n          cl: /(\\[\\^|\\])/,\n        },\n      };\n    }\n    rest.img = {\n      pattern: /!\\[.*?\\]\\(.+?\\)/g,\n      inside: {\n        'cl cl-title': /['‘][^'’]*['’]|[\"“][^\"”]*[\"”](?=\\)$)/,\n        'cl cl-src': {\n          pattern: /(\\]\\()[^('\" \\t]+(?=[)'\" \\t])/,\n          lookbehind: true,\n        },\n      },\n    };\n    if (options.imgsize) {\n      rest.img.inside['cl cl-size'] = /=\\d*x\\d*/;\n    }\n    rest.link = {\n      pattern: /\\[.*?\\]\\(.+?\\)/gm,\n      inside: {\n        'cl cl-underlined-text': {\n          pattern: /(\\[)[^\\]]*/,\n          lookbehind: true,\n        },\n        'cl cl-title': /['‘][^'’]*['’]|[\"“][^\"”]*[\"”](?=\\)$)/,\n      },\n    };\n    rest.imgref = {\n      pattern: /!\\[.*?\\][ \\t]*\\[.*?\\]/g,\n    };\n    rest.linkref = {\n      pattern: /\\[.*?\\][ \\t]*\\[.*?\\]/g,\n      inside: {\n        'cl cl-underlined-text': {\n          pattern: /^(\\[)[^\\]]*(?=\\][ \\t]*\\[)/,\n          lookbehind: true,\n        },\n      },\n    };\n    rest.comment = markup.comment;\n    rest.tag = markup.tag;\n    rest.url = urlPattern;\n    rest.email = emailPattern;\n    rest.strong = {\n      pattern: /(^|[^\\w*])(__|\\*\\*)(?![_*])[\\s\\S]*?\\2(?=([^\\w*]|$))/gm,\n      lookbehind: true,\n      inside: {\n        'cl cl-strong cl-start': /^(__|\\*\\*)/,\n        'cl cl-strong cl-close': /(__|\\*\\*)$/,\n      },\n    };\n    rest.em = {\n      pattern: /(^|[^\\w*])(_|\\*)(?![_*])[\\s\\S]*?\\2(?=([^\\w*]|$))/gm,\n      lookbehind: true,\n      inside: {\n        'cl cl-em cl-start': /^(_|\\*)/,\n        'cl cl-em cl-close': /(_|\\*)$/,\n      },\n    };\n    rest['strong em'] = {\n      pattern: /(^|[^\\w*])(__|\\*\\*)(_|\\*)(?![_*])[\\s\\S]*?\\3\\2(?=([^\\w*]|$))/gm,\n      lookbehind: true,\n      inside: {\n        'cl cl-strong cl-start': /^(__|\\*\\*)(_|\\*)/,\n        'cl cl-strong cl-close': /(_|\\*)(__|\\*\\*)$/,\n      },\n    };\n    rest['strong em inv'] = {\n      pattern: /(^|[^\\w*])(_|\\*)(__|\\*\\*)(?![_*])[\\s\\S]*?\\3\\2(?=([^\\w*]|$))/gm,\n      lookbehind: true,\n      inside: {\n        'cl cl-strong cl-start': /^(_|\\*)(__|\\*\\*)/,\n        'cl cl-strong cl-close': /(__|\\*\\*)(_|\\*)$/,\n      },\n    };\n    if (options.del) {\n      rest.del = {\n        pattern: /(^|[^\\w*])(~~)[\\s\\S]*?\\2(?=([^\\w*]|$))/gm,\n        lookbehind: true,\n        inside: {\n          cl: /~~/,\n          'cl-del-text': /[^~]+/,\n        },\n      };\n    }\n    if (options.mark) {\n      rest.mark = {\n        pattern: /(^|[^\\w*])(==)[\\s\\S]*?\\2(?=([^\\w*]|$))/gm,\n        lookbehind: true,\n        inside: {\n          cl: /==/,\n          'cl-mark-text': /[^=]+/,\n        },\n      };\n    }\n    if (options.sub) {\n      rest.sub = {\n        pattern: /(~)(?=\\S)(.*?\\S)\\1/gm,\n        inside: {\n          cl: /~/,\n        },\n      };\n    }\n    if (options.sup) {\n      rest.sup = {\n        pattern: /(\\^)(?=\\S)(.*?\\S)\\1/gm,\n        inside: {\n          cl: /\\^/,\n        },\n      };\n    }\n    rest.entity = markup.entity;\n\n    for (let c = 6; c >= 1; c -= 1) {\n      grammars.main[`h${c}`].inside.rest = rest;\n    }\n    grammars.main['h1 alt'].inside.rest = rest;\n    grammars.main['h2 alt'].inside.rest = rest;\n    grammars.table.table.inside.rest = rest;\n    grammars.main.rest = rest;\n    grammars.list.rest = rest;\n    grammars.blockquote.blockquote.inside.rest = rest;\n    grammars.deflist.deflist.inside.rest = rest;\n    if (options.footnote) {\n      grammars.main.fndef.inside.rest = rest;\n    }\n\n    const restLight = {\n      code: rest.code,\n      inlinefn: rest.inlinefn,\n      fn: rest.fn,\n      link: rest.link,\n      linkref: rest.linkref,\n    };\n    rest.strong.inside.rest = restLight;\n    rest.em.inside.rest = restLight;\n    if (options.del) {\n      rest.del.inside.rest = restLight;\n    }\n    if (options.mark) {\n      rest.mark.inside.rest = restLight;\n    }\n\n    const inside = {\n      code: rest.code,\n      comment: rest.comment,\n      tag: rest.tag,\n      strong: rest.strong,\n      em: rest.em,\n      del: rest.del,\n      sub: rest.sub,\n      sup: rest.sup,\n      entity: markup.entity,\n    };\n    rest.link.inside['cl cl-underlined-text'].inside = inside;\n    rest.linkref.inside['cl cl-underlined-text'].inside = inside;\n\n    // Wrap any other characters to allow paragraph folding\n    Object.entries(grammars).forEach(([, grammar]) => {\n      grammar.rest = grammar.rest || {};\n      grammar.rest.p = /.+/;\n    });\n\n    return grammars;\n  },\n};\n"
  },
  {
    "path": "src/services/networkSvc.js",
    "content": "import utils from './utils';\nimport store from '../store';\nimport constants from '../data/constants';\n\nconst scriptLoadingPromises = Object.create(null);\nconst authorizeTimeout = 6 * 60 * 1000; // 2 minutes\nconst silentAuthorizeTimeout = 15 * 1000; // 15 secondes (which will be reattempted)\nconst networkTimeout = 30 * 1000; // 30 sec\nlet isConnectionDown = false;\nconst userInactiveAfter = 3 * 60 * 1000; // 3 minutes (twice the default sync period)\nlet lastActivity = 0;\nlet lastFocus = 0;\nlet isConfLoading = false;\nlet isConfLoaded = false;\n\nfunction parseHeaders(xhr) {\n  const pairs = xhr.getAllResponseHeaders().trim().split('\\n');\n  const headers = {};\n  pairs.forEach((header) => {\n    const split = header.trim().split(':');\n    const key = split.shift().trim().toLowerCase();\n    const value = split.join(':').trim();\n    headers[key] = value;\n  });\n  return headers;\n}\n\nfunction isRetriable(err) {\n  if (err.status === 403) {\n    const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason;\n    return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded';\n  }\n  return err.status === 429 || (err.status >= 500 && err.status < 600);\n}\n\nexport default {\n  async init() {\n    // Keep track of the last user activity\n    const setLastActivity = () => {\n      lastActivity = Date.now();\n    };\n    window.document.addEventListener('mousedown', setLastActivity);\n    window.document.addEventListener('keydown', setLastActivity);\n    window.document.addEventListener('touchstart', setLastActivity);\n\n    // Keep track of the last window focus\n    lastFocus = 0;\n    const setLastFocus = () => {\n      lastFocus = Date.now();\n      localStorage.setItem(store.getters['workspace/lastFocusKey'], lastFocus);\n      setLastActivity();\n    };\n    if (document.hasFocus()) {\n      setLastFocus();\n    }\n    window.addEventListener('focus', setLastFocus);\n\n    // Check that browser is online periodically\n    const checkOffline = async () => {\n      const isBrowserOffline = window.navigator.onLine === false;\n      if (!isBrowserOffline\n        && store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now()\n        && this.isUserActive()\n      ) {\n        store.commit('updateLastOfflineCheck');\n        const script = document.createElement('script');\n        let timeout;\n        try {\n          await new Promise((resolve, reject) => {\n            script.onload = resolve;\n            script.onerror = reject;\n            script.src = `https://apis.google.com/js/api.js?${Date.now()}`;\n            try {\n              document.head.appendChild(script); // This can fail with bad network\n              timeout = setTimeout(reject, networkTimeout);\n            } catch (e) {\n              reject(e);\n            }\n          });\n          isConnectionDown = false;\n        } catch (e) {\n          isConnectionDown = true;\n        } finally {\n          clearTimeout(timeout);\n          document.head.removeChild(script);\n        }\n      }\n      const offline = isBrowserOffline || isConnectionDown;\n      if (store.state.offline !== offline) {\n        store.commit('setOffline', offline);\n        if (offline) {\n          store.dispatch('notification/error', 'You are offline.');\n        } else {\n          store.dispatch('notification/info', 'You are back online!');\n          this.getServerConf();\n        }\n      }\n    };\n\n    utils.setInterval(checkOffline, 1000);\n    window.addEventListener('online', () => {\n      isConnectionDown = false;\n      checkOffline();\n    });\n    window.addEventListener('offline', checkOffline);\n    await checkOffline();\n    this.getServerConf();\n  },\n  async getServerConf() {\n    if (!store.state.offline && !isConfLoading && !isConfLoaded) {\n      try {\n        isConfLoading = true;\n        const res = await this.request({ url: 'conf' });\n        await store.dispatch('data/setServerConf', res.body);\n        isConfLoaded = true;\n      } finally {\n        isConfLoading = false;\n      }\n    }\n  },\n  isWindowFocused() {\n    // We don't use state.workspace.lastFocus as it's not reactive\n    const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']);\n    return parseInt(storedLastFocus, 10) === lastFocus;\n  },\n  isUserActive() {\n    return lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused();\n  },\n  isConfLoaded() {\n    return !!Object.keys(store.getters['data/serverConf']).length;\n  },\n  async loadScript(url) {\n    if (!scriptLoadingPromises[url]) {\n      scriptLoadingPromises[url] = new Promise((resolve, reject) => {\n        const script = document.createElement('script');\n        script.onload = resolve;\n        script.onerror = () => {\n          scriptLoadingPromises[url] = null;\n          reject();\n        };\n        script.src = url;\n        document.head.appendChild(script);\n      });\n    }\n    return scriptLoadingPromises[url];\n  },\n  async startOauth2(url, params = {}, silent = false, reattempt = false) {\n    try {\n      // Build the authorize URL\n      const state = utils.uid();\n      const authorizeUrl = utils.addQueryParams(url, {\n        ...params,\n        state,\n        redirect_uri: constants.oauth2RedirectUri,\n      });\n\n      let iframeElt;\n      let wnd;\n      if (silent) {\n        // Use an iframe as wnd for silent mode\n        iframeElt = utils.createHiddenIframe(authorizeUrl);\n        document.body.appendChild(iframeElt);\n        wnd = iframeElt.contentWindow;\n      } else {\n        // Open a tab otherwise\n        wnd = window.open(authorizeUrl);\n        if (!wnd) {\n          throw new Error('The authorize window was blocked.');\n        }\n      }\n\n      let checkClosedInterval;\n      let closeTimeout;\n      let msgHandler;\n      try {\n        return await new Promise((resolve, reject) => {\n          if (silent) {\n            iframeElt.onerror = () => {\n              reject(new Error('Unknown error.'));\n            };\n            closeTimeout = setTimeout(() => {\n              if (!reattempt) {\n                reject(new Error('REATTEMPT'));\n              } else {\n                isConnectionDown = true;\n                store.commit('setOffline', true);\n                store.commit('updateLastOfflineCheck');\n                reject(new Error('You are offline.'));\n              }\n            }, silentAuthorizeTimeout);\n          } else {\n            closeTimeout = setTimeout(() => {\n              reject(new Error('Timeout.'));\n            }, authorizeTimeout);\n          }\n\n          msgHandler = (event) => {\n            if (event.source === wnd && event.origin === constants.origin) {\n              const data = utils.parseQueryParams(`${event.data}`.slice(1));\n              if (data.error || data.state !== state) {\n                console.error(data); // eslint-disable-line no-console\n                reject(new Error('Could not get required authorization.'));\n              } else {\n                resolve({\n                  accessToken: data.access_token,\n                  code: data.code,\n                  idToken: data.id_token,\n                  expiresIn: data.expires_in,\n                });\n              }\n            }\n          };\n\n          window.addEventListener('message', msgHandler);\n          if (!silent) {\n            checkClosedInterval = setInterval(() => {\n              if (wnd.closed) {\n                reject(new Error('Authorize window was closed.'));\n              }\n            }, 250);\n          }\n        });\n      } finally {\n        clearInterval(checkClosedInterval);\n        if (!silent && !wnd.closed) {\n          wnd.close();\n        }\n        if (iframeElt) {\n          document.body.removeChild(iframeElt);\n        }\n        clearTimeout(closeTimeout);\n        window.removeEventListener('message', msgHandler);\n      }\n    } catch (e) {\n      if (e.message === 'REATTEMPT') {\n        return this.startOauth2(url, params, silent, true);\n      }\n      throw e;\n    }\n  },\n  async request(config, offlineCheck = false) {\n    let retryAfter = 500; // 500 ms\n    const maxRetryAfter = 10 * 1000; // 10 sec\n    const sanitizedConfig = Object.assign({}, config);\n    sanitizedConfig.timeout = sanitizedConfig.timeout || networkTimeout;\n    sanitizedConfig.headers = Object.assign({}, sanitizedConfig.headers);\n    if (sanitizedConfig.body && typeof sanitizedConfig.body === 'object') {\n      sanitizedConfig.body = JSON.stringify(sanitizedConfig.body);\n      sanitizedConfig.headers['Content-Type'] = 'application/json';\n    }\n\n    const attempt = async () => {\n      try {\n        return await new Promise((resolve, reject) => {\n          if (offlineCheck) {\n            store.commit('updateLastOfflineCheck');\n          }\n\n          const xhr = new window.XMLHttpRequest();\n          xhr.withCredentials = sanitizedConfig.withCredentials || false;\n\n          const timeoutId = setTimeout(() => {\n            xhr.abort();\n            if (offlineCheck) {\n              isConnectionDown = true;\n              store.commit('setOffline', true);\n              reject(new Error('You are offline.'));\n            } else {\n              reject(new Error('Network request timeout.'));\n            }\n          }, sanitizedConfig.timeout);\n\n          xhr.onload = () => {\n            if (offlineCheck) {\n              isConnectionDown = false;\n            }\n            clearTimeout(timeoutId);\n            const result = {\n              status: xhr.status,\n              headers: parseHeaders(xhr),\n              body: sanitizedConfig.blob ? xhr.response : xhr.responseText,\n            };\n            if (!sanitizedConfig.raw && !sanitizedConfig.blob) {\n              try {\n                result.body = JSON.parse(result.body);\n              } catch (e) {\n                // ignore\n              }\n            }\n            if (result.status >= 200 && result.status < 300) {\n              resolve(result);\n            } else {\n              reject(result);\n            }\n          };\n\n          xhr.onerror = () => {\n            clearTimeout(timeoutId);\n            if (offlineCheck) {\n              isConnectionDown = true;\n              store.commit('setOffline', true);\n              reject(new Error('You are offline.'));\n            } else {\n              reject(new Error('Network request failed.'));\n            }\n          };\n\n          const url = utils.addQueryParams(sanitizedConfig.url, sanitizedConfig.params);\n          xhr.open(sanitizedConfig.method || 'GET', url);\n          Object.entries(sanitizedConfig.headers).forEach(([key, value]) => {\n            if (value) {\n              xhr.setRequestHeader(key, `${value}`);\n            }\n          });\n          if (sanitizedConfig.blob) {\n            xhr.responseType = 'blob';\n          }\n          xhr.send(sanitizedConfig.body || null);\n        });\n      } catch (err) {\n        // Try again later in case of retriable error\n        if (isRetriable(err) && retryAfter < maxRetryAfter) {\n          await new Promise((resolve) => {\n            setTimeout(resolve, retryAfter);\n            // Exponential backoff\n            retryAfter *= 2;\n          });\n          return attempt();\n        }\n        throw err;\n      }\n    };\n\n    return attempt();\n  },\n};\n"
  },
  {
    "path": "src/services/optional/index.js",
    "content": "import './shortcuts';\nimport './keystrokes';\nimport './scrollSync';\nimport './taskChange';\n"
  },
  {
    "path": "src/services/optional/keystrokes.js",
    "content": "import cledit from '../editor/cledit';\nimport editorSvc from '../editorSvc';\nimport store from '../../store';\n\nconst { Keystroke } = cledit;\nconst indentRegexp = /^ {0,3}>[ ]*|^[ \\t]*[*+-][ \\t](?:\\[[ xX]\\][ \\t])?|^([ \\t]*)\\d+\\.[ \\t](?:\\[[ xX]\\][ \\t])?|^\\s+/;\nlet clearNewline;\nlet lastSelection;\n\nfunction fixNumberedList(state, indent) {\n  if (state.selection\n    || indent === undefined\n    || !store.getters['data/computedSettings'].editor.listAutoNumber\n  ) {\n    return;\n  }\n  const spaceIndent = indent.replace(/\\t/g, '    ');\n  const indentRegex = new RegExp(`^[ \\\\s]*$|^${spaceIndent}(\\\\d+\\\\.[ \\\\t])?(( )?.*)$`);\n\n  function getHits(lines) {\n    let hits = [];\n    let pendingHits = [];\n\n    function flush() {\n      if (!pendingHits.hasHit && pendingHits.hasNoIndent) {\n        return false;\n      }\n      hits = hits.concat(pendingHits);\n      pendingHits = [];\n      return true;\n    }\n\n    lines.some((line) => {\n      const match = line.replace(\n        /^[ \\t]*/,\n        wholeMatch => wholeMatch.replace(/\\t/g, '    '),\n      ).match(indentRegex);\n      if (!match || line.match(/^#+ /)) { // Line not empty, not indented, or title\n        flush();\n        return true;\n      }\n      pendingHits.push({\n        line,\n        match,\n      });\n      if (match[2] !== undefined) {\n        if (match[1]) {\n          pendingHits.hasHit = true;\n        } else if (!match[3]) {\n          pendingHits.hasNoIndent = true;\n        }\n      } else if (!flush()) {\n        return true;\n      }\n      return false;\n    });\n    return hits;\n  }\n\n  function formatHits(hits) {\n    let num;\n    return hits.map((hit) => {\n      if (hit.match[1]) {\n        if (!num) {\n          num = parseInt(hit.match[1], 10);\n        }\n        const result = indent + num + hit.match[1].slice(-2) + hit.match[2];\n        num += 1;\n        return result;\n      }\n      return hit.line;\n    });\n  }\n\n  const before = state.before.split('\\n');\n  before.unshift(''); // Add an extra line (fixes #184)\n  const after = state.after.split('\\n');\n  let currentLine = before.pop() || '';\n  const currentPos = currentLine.length;\n  currentLine += after.shift() || '';\n  let lines = before.concat(currentLine).concat(after);\n  let idx = before.length - getHits(before.slice().reverse()).length; // Prevents starting from 0\n  while (idx <= before.length + 1) {\n    const hits = formatHits(getHits(lines.slice(idx)));\n    if (!hits.length) {\n      idx += 1;\n    } else {\n      lines = lines.slice(0, idx).concat(hits).concat(lines.slice(idx + hits.length));\n      idx += hits.length;\n    }\n  }\n  currentLine = lines[before.length];\n  state.before = lines.slice(1, before.length); // As we've added an extra line\n  state.before.push(currentLine.slice(0, currentPos));\n  state.before = state.before.join('\\n');\n  state.after = [currentLine.slice(currentPos)].concat(lines.slice(before.length + 1));\n  state.after = state.after.join('\\n');\n}\n\nfunction enterKeyHandler(evt, state) {\n  if (evt.which !== 13) {\n    // Not enter\n    clearNewline = false;\n    return false;\n  }\n\n  evt.preventDefault();\n\n  // Get the last line before the selection\n  const lastLf = state.before.lastIndexOf('\\n') + 1;\n  const lastLine = state.before.slice(lastLf);\n  // See if the line is indented\n  const indentMatch = lastLine.match(indentRegexp) || [''];\n  if (clearNewline && !state.selection && state.before.length === lastSelection) {\n    state.before = state.before.substring(0, lastLf);\n    state.selection = '';\n    clearNewline = false;\n    fixNumberedList(state, indentMatch[1]);\n    return true;\n  }\n  clearNewline = false;\n  const indent = indentMatch[0];\n  if (indent.length) {\n    clearNewline = true;\n  }\n\n  editorSvc.clEditor.undoMgr.setCurrentMode('single');\n\n  state.before += `\\n${indent}`;\n  state.selection = '';\n  lastSelection = state.before.length;\n  fixNumberedList(state, indentMatch[1]);\n  return true;\n}\n\nfunction tabKeyHandler(evt, state) {\n  if (evt.which !== 9 || evt.metaKey || evt.ctrlKey) {\n    // Not tab\n    return false;\n  }\n\n  const strSplice = (str, i, remove, add) =>\n    str.slice(0, i) + (add || '') + str.slice(i + (+remove || 0));\n\n  evt.preventDefault();\n  const isInverse = evt.shiftKey;\n  const lastLf = state.before.lastIndexOf('\\n') + 1;\n  const lastLine = state.before.slice(lastLf);\n  const currentLine = lastLine + state.selection + state.after;\n  const indentMatch = currentLine.match(indentRegexp);\n  if (isInverse) {\n    const previousChar = state.before.slice(-1);\n    if (/\\s/.test(state.before.charAt(lastLf))) {\n      state.before = strSplice(state.before, lastLf, 1);\n      if (indentMatch) {\n        fixNumberedList(state, indentMatch[1]);\n        if (indentMatch[1]) {\n          fixNumberedList(state, indentMatch[1].slice(1));\n        }\n      }\n    }\n    const selection = previousChar + state.selection;\n    state.selection = selection.replace(/\\n[ \\t]/gm, '\\n');\n    if (previousChar) {\n      state.selection = state.selection.slice(1);\n    }\n  } else if (\n    // If selection is not empty\n    state.selection\n    // Or we are in an indented paragraph and the cursor is over the indentation characters\n    || (indentMatch && indentMatch[0].length >= lastLine.length)\n  ) {\n    state.before = strSplice(state.before, lastLf, 0, '\\t');\n    state.selection = state.selection.replace(/\\n(?=.)/g, '\\n\\t');\n    if (indentMatch) {\n      fixNumberedList(state, indentMatch[1]);\n      fixNumberedList(state, `\\t${indentMatch[1]}`);\n    }\n  } else {\n    state.before += '\\t';\n  }\n  return true;\n}\n\neditorSvc.$on('inited', () => {\n  editorSvc.clEditor.addKeystroke(new Keystroke(enterKeyHandler, 50));\n  editorSvc.clEditor.addKeystroke(new Keystroke(tabKeyHandler, 50));\n});\n"
  },
  {
    "path": "src/services/optional/scrollSync.js",
    "content": "import store from '../../store';\nimport animationSvc from '../animationSvc';\nimport editorSvc from '../editorSvc';\n\nlet editorScrollerElt;\nlet previewScrollerElt;\nlet editorFinishTimeoutId;\nlet previewFinishTimeoutId;\nlet skipAnimation;\nlet isScrollEditor;\nlet isScrollPreview;\nlet isEditorMoving;\nlet isPreviewMoving;\nlet sectionDescList = [];\n\nlet throttleTimeoutId;\nlet throttleLastTime = 0;\n\nfunction throttle(func, wait) {\n  clearTimeout(throttleTimeoutId);\n  const currentTime = Date.now();\n  const localWait = (wait + throttleLastTime) - currentTime;\n  if (localWait < 1) {\n    throttleLastTime = currentTime;\n    func();\n  } else {\n    throttleTimeoutId = setTimeout(() => {\n      throttleLastTime = Date.now();\n      func();\n    }, localWait);\n  }\n}\n\nconst doScrollSync = () => {\n  const localSkipAnimation = skipAnimation || !store.getters['layout/styles'].showSidePreview;\n  skipAnimation = false;\n  if (!store.getters['data/layoutSettings'].scrollSync || sectionDescList.length === 0) {\n    return;\n  }\n  let editorScrollTop = editorScrollerElt.scrollTop;\n  if (editorScrollTop < 0) {\n    editorScrollTop = 0;\n  }\n  const previewScrollTop = previewScrollerElt.scrollTop;\n  let scrollTo;\n  if (isScrollEditor) {\n    // Scroll the preview\n    isScrollEditor = false;\n    sectionDescList.some((sectionDesc) => {\n      if (editorScrollTop > sectionDesc.editorDimension.endOffset) {\n        return false;\n      }\n      const posInSection = (editorScrollTop - sectionDesc.editorDimension.startOffset)\n        / (sectionDesc.editorDimension.height || 1);\n      scrollTo = (sectionDesc.previewDimension.startOffset\n        + (sectionDesc.previewDimension.height * posInSection));\n      return true;\n    });\n    scrollTo = Math.min(\n      scrollTo,\n      previewScrollerElt.scrollHeight - previewScrollerElt.offsetHeight,\n    );\n\n    throttle(() => {\n      clearTimeout(previewFinishTimeoutId);\n      animationSvc.animate(previewScrollerElt)\n        .scrollTop(scrollTo)\n        .duration(!localSkipAnimation && 100)\n        .start(() => {\n          previewFinishTimeoutId = setTimeout(() => {\n            isPreviewMoving = false;\n          }, 100);\n        }, () => {\n          isPreviewMoving = true;\n        });\n    }, localSkipAnimation ? 500 : 50);\n  } else if (!store.getters['layout/styles'].showEditor || isScrollPreview) {\n    // Scroll the editor\n    isScrollPreview = false;\n    sectionDescList.some((sectionDesc) => {\n      if (previewScrollTop > sectionDesc.previewDimension.endOffset) {\n        return false;\n      }\n      const posInSection = (previewScrollTop - sectionDesc.previewDimension.startOffset)\n        / (sectionDesc.previewDimension.height || 1);\n      scrollTo = (sectionDesc.editorDimension.startOffset\n        + (sectionDesc.editorDimension.height * posInSection));\n      return true;\n    });\n    scrollTo = Math.min(\n      scrollTo,\n      editorScrollerElt.scrollHeight - editorScrollerElt.offsetHeight,\n    );\n\n    throttle(() => {\n      clearTimeout(editorFinishTimeoutId);\n      animationSvc.animate(editorScrollerElt)\n        .scrollTop(scrollTo)\n        .duration(!localSkipAnimation && 100)\n        .start(() => {\n          editorFinishTimeoutId = setTimeout(() => {\n            isEditorMoving = false;\n          }, 100);\n        }, () => {\n          isEditorMoving = true;\n        });\n    }, localSkipAnimation ? 500 : 50);\n  }\n};\n\nlet isPreviewRefreshing;\nlet timeoutId;\n\nconst forceScrollSync = () => {\n  if (!isPreviewRefreshing) {\n    doScrollSync();\n  }\n};\nstore.watch(() => store.getters['data/layoutSettings'].scrollSync, forceScrollSync);\n\neditorSvc.$on('inited', () => {\n  editorScrollerElt = editorSvc.editorElt.parentNode;\n  previewScrollerElt = editorSvc.previewElt.parentNode;\n\n  editorScrollerElt.addEventListener('scroll', () => {\n    if (isEditorMoving) {\n      return;\n    }\n    isScrollEditor = true;\n    isScrollPreview = false;\n    doScrollSync();\n  });\n\n  previewScrollerElt.addEventListener('scroll', () => {\n    if (isPreviewMoving || isPreviewRefreshing) {\n      return;\n    }\n    isScrollPreview = true;\n    isScrollEditor = false;\n    doScrollSync();\n  });\n});\n\neditorSvc.$on('sectionList', () => {\n  clearTimeout(timeoutId);\n  isPreviewRefreshing = true;\n  sectionDescList = [];\n});\n\neditorSvc.$on('previewCtx', () => {\n  // Assume the user is writing in the editor\n  isScrollEditor = store.getters['layout/styles'].showEditor;\n  // A preview scrolling event can occur if height is smaller\n  timeoutId = setTimeout(() => {\n    isPreviewRefreshing = false;\n  }, 100);\n});\n\nstore.watch(\n  () => store.getters['layout/styles'].showEditor,\n  (showEditor) => {\n    isScrollEditor = showEditor;\n    isScrollPreview = !showEditor;\n    skipAnimation = true;\n  },\n);\n\nstore.watch(\n  () => store.getters['file/current'].id,\n  () => {\n    skipAnimation = true;\n  },\n);\n\neditorSvc.$on('previewCtxMeasured', (previewCtxMeasured) => {\n  if (previewCtxMeasured) {\n    ({ sectionDescList } = previewCtxMeasured);\n    forceScrollSync();\n  }\n});\n"
  },
  {
    "path": "src/services/optional/shortcuts.js",
    "content": "import Mousetrap from 'mousetrap';\nimport store from '../../store';\nimport editorSvc from '../../services/editorSvc';\nimport syncSvc from '../../services/syncSvc';\n\n// Skip shortcuts if modal is open or editor is hidden\nMousetrap.prototype.stopCallback = () => store.getters['modal/config'] || !store.getters['content/isCurrentEditable'];\n\nconst pagedownHandler = name => () => {\n  editorSvc.pagedownEditor.uiManager.doClick(name);\n  return true;\n};\n\nconst findReplaceOpener = type => () => {\n  store.dispatch('findReplace/open', {\n    type,\n    findText: editorSvc.clEditor.selectionMgr.hasFocus() &&\n      editorSvc.clEditor.selectionMgr.getSelectedText(),\n  });\n  return true;\n};\n\nconst methods = {\n  bold: pagedownHandler('bold'),\n  italic: pagedownHandler('italic'),\n  strikethrough: pagedownHandler('strikethrough'),\n  link: pagedownHandler('link'),\n  quote: pagedownHandler('quote'),\n  code: pagedownHandler('code'),\n  image: pagedownHandler('image'),\n  olist: pagedownHandler('olist'),\n  ulist: pagedownHandler('ulist'),\n  clist: pagedownHandler('clist'),\n  heading: pagedownHandler('heading'),\n  hr: pagedownHandler('hr'),\n  sync() {\n    if (syncSvc.isSyncPossible()) {\n      syncSvc.requestSync();\n    }\n    return true;\n  },\n  find: findReplaceOpener('find'),\n  replace: findReplaceOpener('replace'),\n  expand(param1, param2) {\n    const text = `${param1 || ''}`;\n    const replacement = `${param2 || ''}`;\n    if (text && replacement) {\n      setTimeout(() => {\n        const { selectionMgr } = editorSvc.clEditor;\n        let offset = selectionMgr.selectionStart;\n        if (offset === selectionMgr.selectionEnd) {\n          const range = selectionMgr.createRange(offset - text.length, offset);\n          if (`${range}` === text) {\n            range.deleteContents();\n            range.insertNode(document.createTextNode(replacement));\n            offset = (offset - text.length) + replacement.length;\n            selectionMgr.setSelectionStartEnd(offset, offset);\n            selectionMgr.updateCursorCoordinates(true);\n          }\n        }\n      }, 1);\n    }\n  },\n};\n\nstore.watch(\n  () => store.getters['data/computedSettings'],\n  (computedSettings) => {\n    Mousetrap.reset();\n\n    Object.entries(computedSettings.shortcuts).forEach(([key, shortcut]) => {\n      if (shortcut) {\n        const method = `${shortcut.method || shortcut}`;\n        let params = shortcut.params || [];\n        if (!Array.isArray(params)) {\n          params = [params];\n        }\n        if (Object.prototype.hasOwnProperty.call(methods, method)) {\n          try {\n            Mousetrap.bind(`${key}`, () => !methods[method].apply(null, params));\n          } catch (e) {\n            // Ignore\n          }\n        }\n      }\n    });\n  }, {\n    immediate: true,\n  },\n);\n"
  },
  {
    "path": "src/services/optional/taskChange.js",
    "content": "import editorSvc from '../editorSvc';\nimport store from '../../store';\n\neditorSvc.$on('inited', () => {\n  const getPreviewOffset = (elt) => {\n    let offset = 0;\n    if (!elt || elt === editorSvc.previewElt) {\n      return offset;\n    }\n    let { previousSibling } = elt;\n    while (previousSibling) {\n      offset += previousSibling.textContent.length;\n      ({ previousSibling } = previousSibling);\n    }\n    return offset + getPreviewOffset(elt.parentNode);\n  };\n\n  editorSvc.previewElt.addEventListener('click', (evt) => {\n    if (evt.target.classList.contains('task-list-item-checkbox')) {\n      evt.preventDefault();\n      if (store.getters['content/isCurrentEditable']) {\n        const editorContent = editorSvc.clEditor.getContent();\n        // Use setTimeout to ensure evt.target.checked has the old value\n        setTimeout(() => {\n          // Make sure content has not changed\n          if (editorContent === editorSvc.clEditor.getContent()) {\n            const previewOffset = getPreviewOffset(evt.target);\n            const endOffset = editorSvc.getEditorOffset(previewOffset + 1);\n            if (endOffset != null) {\n              const startOffset = editorContent.lastIndexOf('\\n', endOffset) + 1;\n              const line = editorContent.slice(startOffset, endOffset);\n              const match = line.match(/^([ \\t]*(?:[*+-]|\\d+\\.)[ \\t]+\\[)[ xX](\\] .*)/);\n              if (match) {\n                let newContent = editorContent.slice(0, startOffset);\n                newContent += match[1];\n                newContent += evt.target.checked ? ' ' : 'x';\n                newContent += match[2];\n                newContent += editorContent.slice(endOffset);\n                editorSvc.clEditor.setContent(newContent, true);\n              }\n            }\n          }\n        }, 10);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "src/services/providers/bloggerPageProvider.js",
    "content": "import store from '../../store';\nimport googleHelper from './helpers/googleHelper';\nimport Provider from './common/Provider';\n\nexport default new Provider({\n  id: 'bloggerPage',\n  name: 'Blogger Page',\n  getToken({ sub }) {\n    const token = store.getters['data/googleTokensBySub'][sub];\n    return token && token.isBlogger ? token : null;\n  },\n  getLocationUrl({ blogId, pageId }) {\n    return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=page;pageID=${pageId}`;\n  },\n  getLocationDescription({ pageId }) {\n    return pageId;\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const page = await googleHelper.uploadBlogger({\n      token,\n      blogUrl: publishLocation.blogUrl,\n      blogId: publishLocation.blogId,\n      postId: publishLocation.pageId,\n      title: metadata.title,\n      content: html,\n      isPage: true,\n    });\n    return {\n      ...publishLocation,\n      blogId: page.blog.id,\n      pageId: page.id,\n    };\n  },\n  makeLocation(token, blogUrl, pageId) {\n    const location = {\n      providerId: this.id,\n      sub: token.sub,\n      blogUrl,\n    };\n    if (pageId) {\n      location.pageId = pageId;\n    }\n    return location;\n  },\n});\n"
  },
  {
    "path": "src/services/providers/bloggerProvider.js",
    "content": "import store from '../../store';\nimport googleHelper from './helpers/googleHelper';\nimport Provider from './common/Provider';\n\nexport default new Provider({\n  id: 'blogger',\n  name: 'Blogger',\n  getToken({ sub }) {\n    const token = store.getters['data/googleTokensBySub'][sub];\n    return token && token.isBlogger ? token : null;\n  },\n  getLocationUrl({ blogId, postId }) {\n    return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=post;postID=${postId}`;\n  },\n  getLocationDescription({ postId }) {\n    return postId;\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const post = await googleHelper.uploadBlogger({\n      ...publishLocation,\n      token,\n      title: metadata.title,\n      content: html,\n      labels: metadata.tags,\n      isDraft: metadata.status === 'draft',\n      published: metadata.date,\n    });\n    return {\n      ...publishLocation,\n      blogId: post.blog.id,\n      postId: post.id,\n    };\n  },\n  makeLocation(token, blogUrl, postId) {\n    const location = {\n      providerId: this.id,\n      sub: token.sub,\n      blogUrl,\n    };\n    if (postId) {\n      location.postId = postId;\n    }\n    return location;\n  },\n});\n"
  },
  {
    "path": "src/services/providers/common/Provider.js",
    "content": "import providerRegistry from './providerRegistry';\nimport emptyContent from '../../../data/empties/emptyContent';\nimport utils from '../../utils';\nimport store from '../../../store';\nimport workspaceSvc from '../../workspaceSvc';\n\nconst dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\\s]+)-->\\s*$/;\n\nexport default class Provider {\n  prepareChanges = changes => changes\n  onChangesApplied = () => {}\n\n  constructor(props) {\n    Object.assign(this, props);\n    providerRegistry.register(this);\n  }\n\n  /**\n   * Serialize content in a self contain Markdown compatible format\n   */\n  static serializeContent(content) {\n    let result = content.text;\n    const data = {};\n    if (content.properties.length > 1) {\n      data.properties = content.properties;\n    }\n    if (Object.keys(content.discussions).length) {\n      data.discussions = content.discussions;\n    }\n    if (Object.keys(content.comments).length) {\n      data.comments = content.comments;\n    }\n    if (content.history && content.history.length) {\n      data.history = content.history;\n    }\n    if (Object.keys(data).length) {\n      const serializedData = utils.encodeBase64(JSON.stringify(data)).replace(/(.{50})/g, '$1\\n');\n      result += `<!--stackedit_data:\\n${serializedData}\\n-->`;\n    }\n    return result;\n  }\n\n  /**\n   * Parse content serialized with serializeContent()\n   */\n  static parseContent(serializedContent, id) {\n    let text = serializedContent;\n    const extractedData = dataExtractor.exec(serializedContent);\n    let result;\n    if (!extractedData) {\n      // In case stackedit's data has been manually removed, try to restore them\n      result = utils.deepCopy(store.state.content.itemsById[id]) || emptyContent(id);\n    } else {\n      result = emptyContent(id);\n      try {\n        const serializedData = extractedData[1].replace(/\\s/g, '');\n        const parsedData = JSON.parse(utils.decodeBase64(serializedData));\n        text = text.slice(0, extractedData.index);\n        if (parsedData.properties) {\n          result.properties = utils.sanitizeText(parsedData.properties);\n        }\n        if (parsedData.discussions) {\n          result.discussions = parsedData.discussions;\n        }\n        if (parsedData.comments) {\n          result.comments = parsedData.comments;\n        }\n        result.history = parsedData.history;\n      } catch (e) {\n        // Ignore\n      }\n    }\n    result.text = utils.sanitizeText(text);\n    if (!result.history) {\n      result.history = [];\n    }\n    return utils.addItemHash(result);\n  }\n\n  /**\n   * Find and open a file with location that meets the criteria\n   */\n  static openFileWithLocation(criteria) {\n    const location = utils.search(store.getters['syncLocation/items'], criteria);\n    if (location) {\n      // Found one, open it if it exists\n      const item = store.state.file.itemsById[location.fileId];\n      if (item) {\n        store.commit('file/setCurrentId', item.id);\n        // If file is in the trash, restore it\n        if (item.parentId === 'trash') {\n          workspaceSvc.setOrPatchItem({\n            ...item,\n            parentId: null,\n          });\n        }\n        return true;\n      }\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/services/providers/common/providerRegistry.js",
    "content": "export default {\n  providersById: {},\n  register(provider) {\n    this.providersById[provider.id] = provider;\n    return provider;\n  },\n};\n"
  },
  {
    "path": "src/services/providers/couchdbWorkspaceProvider.js",
    "content": "import store from '../../store';\nimport couchdbHelper from './helpers/couchdbHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport badgeSvc from '../badgeSvc';\n\nlet syncLastSeq;\n\nexport default new Provider({\n  id: 'couchdbWorkspace',\n  name: 'CouchDB',\n  getToken() {\n    return store.getters['workspace/syncToken'];\n  },\n  getWorkspaceParams({ dbUrl }) {\n    return {\n      providerId: this.id,\n      dbUrl,\n    };\n  },\n  getWorkspaceLocationUrl({ dbUrl }) {\n    return dbUrl;\n  },\n  getSyncDataUrl(fileSyncData, { id }) {\n    const { dbUrl } = this.getToken();\n    return `${dbUrl}/${id}/data`;\n  },\n  getSyncDataDescription(fileSyncData, { id }) {\n    return id;\n  },\n  async initWorkspace() {\n    const dbUrl = (utils.queryParams.dbUrl || '').replace(/\\/?$/, ''); // Remove trailing /\n    const workspaceParams = this.getWorkspaceParams({ dbUrl });\n    const workspaceId = utils.makeWorkspaceId(workspaceParams);\n\n    // Create the token if it doesn't exist\n    if (!store.getters['data/couchdbTokensBySub'][workspaceId]) {\n      store.dispatch('data/addCouchdbToken', {\n        sub: workspaceId,\n        dbUrl,\n      });\n    }\n\n    // Create the workspace if it doesn't exist\n    if (!store.getters['workspace/workspacesById'][workspaceId]) {\n      try {\n        // Make sure the database exists and retrieve its name\n        const db = await couchdbHelper.getDb(store.getters['data/couchdbTokensBySub'][workspaceId]);\n        store.dispatch('workspace/patchWorkspacesById', {\n          [workspaceId]: {\n            id: workspaceId,\n            name: db.db_name,\n            providerId: this.id,\n            dbUrl,\n          },\n        });\n      } catch (e) {\n        throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`);\n      }\n    }\n\n    badgeSvc.addBadge('addCouchdbWorkspace');\n    return store.getters['workspace/workspacesById'][workspaceId];\n  },\n  async getChanges() {\n    const syncToken = store.getters['workspace/syncToken'];\n    const lastSeq = store.getters['data/localSettings'].syncLastSeq;\n    const result = await couchdbHelper.getChanges(syncToken, lastSeq);\n    const changes = result.changes.filter((change) => {\n      if (!change.deleted && change.doc) {\n        change.item = change.doc.item;\n        if (!change.item || !change.item.id || !change.item.type) {\n          return false;\n        }\n        // Build sync data\n        change.syncData = {\n          id: change.id,\n          itemId: change.item.id,\n          type: change.item.type,\n          hash: change.item.hash,\n          rev: change.doc._rev, // eslint-disable-line no-underscore-dangle\n        };\n      }\n      change.syncDataId = change.id;\n      return true;\n    });\n    syncLastSeq = result.lastSeq;\n    return changes;\n  },\n  onChangesApplied() {\n    store.dispatch('data/patchLocalSettings', {\n      syncLastSeq,\n    });\n  },\n  async saveWorkspaceItem({ item, syncData }) {\n    const syncToken = store.getters['workspace/syncToken'];\n    const { id, rev } = await couchdbHelper.uploadDocument({\n      token: syncToken,\n      item,\n      documentId: syncData && syncData.id,\n      rev: syncData && syncData.rev,\n    });\n\n    // Build sync data to save\n    return {\n      syncData: {\n        id,\n        itemId: item.id,\n        type: item.type,\n        hash: item.hash,\n        rev,\n      },\n    };\n  },\n  removeWorkspaceItem({ syncData }) {\n    const syncToken = store.getters['workspace/syncToken'];\n    return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev);\n  },\n  async downloadWorkspaceContent({ token, contentSyncData }) {\n    const body = await couchdbHelper.retrieveDocumentWithAttachments(token, contentSyncData.id);\n    const rev = body._rev; // eslint-disable-line no-underscore-dangle\n    const content = Provider.parseContent(body.attachments.data, body.item.id);\n    return {\n      content,\n      contentSyncData: {\n        ...contentSyncData,\n        hash: content.hash,\n        rev,\n      },\n    };\n  },\n  async downloadWorkspaceData({ token, syncData }) {\n    if (!syncData) {\n      return {};\n    }\n\n    const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id);\n    const item = utils.addItemHash(JSON.parse(body.attachments.data));\n    const rev = body._rev; // eslint-disable-line no-underscore-dangle\n    return {\n      item,\n      syncData: {\n        ...syncData,\n        hash: item.hash,\n        rev,\n      },\n    };\n  },\n  async uploadWorkspaceContent({ token, content, contentSyncData }) {\n    const res = await couchdbHelper.uploadDocument({\n      token,\n      item: {\n        id: content.id,\n        type: content.type,\n        hash: content.hash,\n      },\n      data: Provider.serializeContent(content),\n      dataType: 'text/plain',\n      documentId: contentSyncData && contentSyncData.id,\n      rev: contentSyncData && contentSyncData.rev,\n    });\n\n    // Return new sync data\n    return {\n      contentSyncData: {\n        id: res.id,\n        itemId: content.id,\n        type: content.type,\n        hash: content.hash,\n        rev: res.rev,\n      },\n    };\n  },\n  async uploadWorkspaceData({ token, item, syncData }) {\n    const res = await couchdbHelper.uploadDocument({\n      token,\n      item: {\n        id: item.id,\n        type: item.type,\n        hash: item.hash,\n      },\n      data: JSON.stringify(item),\n      dataType: 'application/json',\n      documentId: syncData && syncData.id,\n      rev: syncData && syncData.rev,\n    });\n\n    // Return new sync data\n    return {\n      syncData: {\n        id: res.id,\n        itemId: item.id,\n        type: item.type,\n        hash: item.hash,\n        rev: res.rev,\n      },\n    };\n  },\n  async listFileRevisions({ token, contentSyncDataId }) {\n    const body = await couchdbHelper.retrieveDocumentWithRevisions(token, contentSyncDataId);\n    const revisions = [];\n    body._revs_info.forEach((revInfo, idx) => { // eslint-disable-line no-underscore-dangle\n      if (revInfo.status === 'available') {\n        revisions.push({\n          id: revInfo.rev,\n          sub: null,\n          created: idx,\n          loaded: false,\n        });\n      }\n    });\n    return revisions;\n  },\n  async loadFileRevision({ token, contentSyncDataId, revision }) {\n    if (revision.loaded) {\n      return false;\n    }\n    const body = await couchdbHelper.retrieveDocument(token, contentSyncDataId, revision.id);\n    revision.sub = body.sub;\n    revision.created = body.time;\n    revision.loaded = true;\n    return true;\n  },\n  async getFileRevisionContent({ token, contentSyncDataId, revisionId }) {\n    const body = await couchdbHelper\n      .retrieveDocumentWithAttachments(token, contentSyncDataId, revisionId);\n    return Provider.parseContent(body.attachments.data, body.item.id);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/dropboxProvider.js",
    "content": "import store from '../../store';\nimport dropboxHelper from './helpers/dropboxHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport workspaceSvc from '../workspaceSvc';\n\nconst makePathAbsolute = (token, path) => {\n  if (!token.fullAccess) {\n    return `/Applications/StackEdit (restricted)${path}`;\n  }\n  return path;\n};\nconst makePathRelative = (token, path) => {\n  if (!token.fullAccess) {\n    return path.replace(/^\\/Applications\\/StackEdit \\(restricted\\)/, '');\n  }\n  return path;\n};\n\nexport default new Provider({\n  id: 'dropbox',\n  name: 'Dropbox',\n  getToken({ sub }) {\n    return store.getters['data/dropboxTokensBySub'][sub];\n  },\n  getLocationUrl({ path }) {\n    const pathComponents = path.split('/').map(encodeURIComponent);\n    const filename = pathComponents.pop();\n    return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`;\n  },\n  getLocationDescription({ path, dropboxFileId }) {\n    return dropboxFileId || path;\n  },\n  checkPath(path) {\n    return path && path.match(/^\\/[^\\\\<>:\"|?*]+$/);\n  },\n  async downloadContent(token, syncLocation) {\n    const { content } = await dropboxHelper.downloadFile({\n      token,\n      path: makePathRelative(token, syncLocation.path),\n      fileId: syncLocation.dropboxFileId,\n    });\n    return Provider.parseContent(content, `${syncLocation.fileId}/content`);\n  },\n  async uploadContent(token, content, syncLocation) {\n    const dropboxFile = await dropboxHelper.uploadFile({\n      token,\n      path: makePathRelative(token, syncLocation.path),\n      content: Provider.serializeContent(content),\n      fileId: syncLocation.dropboxFileId,\n    });\n    return {\n      ...syncLocation,\n      path: makePathAbsolute(token, dropboxFile.path_display),\n      dropboxFileId: dropboxFile.id,\n    };\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const dropboxFile = await dropboxHelper.uploadFile({\n      token,\n      path: publishLocation.path,\n      content: html,\n      fileId: publishLocation.dropboxFileId,\n    });\n    return {\n      ...publishLocation,\n      path: makePathAbsolute(token, dropboxFile.path_display),\n      dropboxFileId: dropboxFile.id,\n    };\n  },\n  async openFiles(token, paths) {\n    await utils.awaitSequence(paths, async (path) => {\n      // Check if the file exists and open it\n      if (!Provider.openFileWithLocation({\n        providerId: this.id,\n        path,\n      })) {\n        // Download content from Dropbox\n        const syncLocation = {\n          path,\n          providerId: this.id,\n          sub: token.sub,\n        };\n        let content;\n        try {\n          content = await this.downloadContent(token, syncLocation);\n        } catch (e) {\n          store.dispatch('notification/error', `Could not open file ${path}.`);\n          return;\n        }\n\n        // Create the file\n        let name = path;\n        const slashPos = name.lastIndexOf('/');\n        if (slashPos > -1 && slashPos < name.length - 1) {\n          name = name.slice(slashPos + 1);\n        }\n        const dotPos = name.lastIndexOf('.');\n        if (dotPos > 0 && slashPos < name.length) {\n          name = name.slice(0, dotPos);\n        }\n        const item = await workspaceSvc.createFile({\n          name,\n          parentId: store.getters['file/current'].parentId,\n          text: content.text,\n          properties: content.properties,\n          discussions: content.discussions,\n          comments: content.comments,\n        }, true);\n        store.commit('file/setCurrentId', item.id);\n        workspaceSvc.addSyncLocation({\n          ...syncLocation,\n          fileId: item.id,\n        });\n        store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`);\n      }\n    });\n  },\n  makeLocation(token, path) {\n    return {\n      providerId: this.id,\n      sub: token.sub,\n      path,\n    };\n  },\n  async listFileRevisions({ token, syncLocation }) {\n    const entries = await dropboxHelper.listRevisions({\n      token,\n      path: makePathRelative(token, syncLocation.path),\n      fileId: syncLocation.dropboxFileId,\n    });\n    return entries.map(entry => ({\n      id: entry.rev,\n      sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`,\n      created: new Date(entry.server_modified).getTime(),\n    }));\n  },\n  async loadFileRevision() {\n    // Revision are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    revisionId,\n  }) {\n    const { content } = await dropboxHelper.downloadFile({\n      token,\n      path: `rev:${revisionId}`,\n    });\n    return Provider.parseContent(content, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/gistProvider.js",
    "content": "import store from '../../store';\nimport githubHelper from './helpers/githubHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport userSvc from '../userSvc';\n\nexport default new Provider({\n  id: 'gist',\n  name: 'Gist',\n  getToken({ sub }) {\n    return store.getters['data/githubTokensBySub'][sub];\n  },\n  getLocationUrl({ gistId }) {\n    return `https://gist.github.com/${gistId}`;\n  },\n  getLocationDescription({ filename }) {\n    return filename;\n  },\n  async downloadContent(token, syncLocation) {\n    const content = await githubHelper.downloadGist({\n      ...syncLocation,\n      token,\n    });\n    return Provider.parseContent(content, `${syncLocation.fileId}/content`);\n  },\n  async uploadContent(token, content, syncLocation) {\n    const file = store.state.file.itemsById[syncLocation.fileId];\n    const description = utils.sanitizeName(file && file.name);\n    const gist = await githubHelper.uploadGist({\n      ...syncLocation,\n      token,\n      description,\n      content: Provider.serializeContent(content),\n    });\n    return {\n      ...syncLocation,\n      gistId: gist.id,\n    };\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const gist = await githubHelper.uploadGist({\n      ...publishLocation,\n      token,\n      description: metadata.title,\n      content: html,\n    });\n    return {\n      ...publishLocation,\n      gistId: gist.id,\n    };\n  },\n  makeLocation(token, filename, isPublic, gistId) {\n    return {\n      providerId: this.id,\n      sub: token.sub,\n      filename,\n      isPublic,\n      gistId,\n    };\n  },\n  async listFileRevisions({ token, syncLocation }) {\n    const entries = await githubHelper.getGistCommits({\n      ...syncLocation,\n      token,\n    });\n\n    return entries.map((entry) => {\n      const sub = `${githubHelper.subPrefix}:${entry.user.id}`;\n      userSvc.addUserInfo({ id: sub, name: entry.user.login, imageUrl: entry.user.avatar_url });\n      return {\n        sub,\n        id: entry.version,\n        created: new Date(entry.committed_at).getTime(),\n      };\n    });\n  },\n  async loadFileRevision() {\n    // Revision are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    syncLocation,\n    revisionId,\n  }) {\n    const data = await githubHelper.downloadGistRevision({\n      ...syncLocation,\n      token,\n      sha: revisionId,\n    });\n    return Provider.parseContent(data, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/githubProvider.js",
    "content": "import store from '../../store';\nimport githubHelper from './helpers/githubHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport workspaceSvc from '../workspaceSvc';\nimport userSvc from '../userSvc';\n\nconst savedSha = {};\n\nexport default new Provider({\n  id: 'github',\n  name: 'GitHub',\n  getToken({ sub }) {\n    return store.getters['data/githubTokensBySub'][sub];\n  },\n  getLocationUrl({\n    owner,\n    repo,\n    branch,\n    path,\n  }) {\n    return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;\n  },\n  getLocationDescription({ path }) {\n    return path;\n  },\n  async downloadContent(token, syncLocation) {\n    const { sha, data } = await githubHelper.downloadFile({\n      ...syncLocation,\n      token,\n    });\n    savedSha[syncLocation.id] = sha;\n    return Provider.parseContent(data, `${syncLocation.fileId}/content`);\n  },\n  async uploadContent(token, content, syncLocation) {\n    if (!savedSha[syncLocation.id]) {\n      try {\n        // Get the last sha\n        await this.downloadContent(token, syncLocation);\n      } catch (e) {\n        // Ignore error\n      }\n    }\n    const sha = savedSha[syncLocation.id];\n    delete savedSha[syncLocation.id];\n    await githubHelper.uploadFile({\n      ...syncLocation,\n      token,\n      content: Provider.serializeContent(content),\n      sha,\n    });\n    return syncLocation;\n  },\n  async publish(token, html, metadata, publishLocation) {\n    try {\n      // Get the last sha\n      await this.downloadContent(token, publishLocation);\n    } catch (e) {\n      // Ignore error\n    }\n    const sha = savedSha[publishLocation.id];\n    delete savedSha[publishLocation.id];\n    await githubHelper.uploadFile({\n      ...publishLocation,\n      token,\n      content: html,\n      sha,\n    });\n    return publishLocation;\n  },\n  async openFile(token, syncLocation) {\n    // Check if the file exists and open it\n    if (!Provider.openFileWithLocation(syncLocation)) {\n      // Download content from GitHub\n      let content;\n      try {\n        content = await this.downloadContent(token, syncLocation);\n      } catch (e) {\n        store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`);\n        return;\n      }\n\n      // Create the file\n      let name = syncLocation.path;\n      const slashPos = name.lastIndexOf('/');\n      if (slashPos > -1 && slashPos < name.length - 1) {\n        name = name.slice(slashPos + 1);\n      }\n      const dotPos = name.lastIndexOf('.');\n      if (dotPos > 0 && slashPos < name.length) {\n        name = name.slice(0, dotPos);\n      }\n      const item = await workspaceSvc.createFile({\n        name,\n        parentId: store.getters['file/current'].parentId,\n        text: content.text,\n        properties: content.properties,\n        discussions: content.discussions,\n        comments: content.comments,\n      }, true);\n      store.commit('file/setCurrentId', item.id);\n      workspaceSvc.addSyncLocation({\n        ...syncLocation,\n        fileId: item.id,\n      });\n      store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`);\n    }\n  },\n  makeLocation(token, owner, repo, branch, path) {\n    return {\n      providerId: this.id,\n      sub: token.sub,\n      owner,\n      repo,\n      branch,\n      path,\n    };\n  },\n  async listFileRevisions({ token, syncLocation }) {\n    const entries = await githubHelper.getCommits({\n      ...syncLocation,\n      token,\n    });\n\n    return entries.map(({\n      author,\n      committer,\n      commit,\n      sha,\n    }) => {\n      let user;\n      if (author && author.login) {\n        user = author;\n      } else if (committer && committer.login) {\n        user = committer;\n      }\n      const sub = `${githubHelper.subPrefix}:${user.id}`;\n      userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url });\n      const date = (commit.author && commit.author.date)\n        || (commit.committer && commit.committer.date);\n      return {\n        id: sha,\n        sub,\n        created: date ? new Date(date).getTime() : 1,\n      };\n    });\n  },\n  async loadFileRevision() {\n    // Revision are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    syncLocation,\n    revisionId,\n  }) {\n    const { data } = await githubHelper.downloadFile({\n      ...syncLocation,\n      token,\n      branch: revisionId,\n    });\n    return Provider.parseContent(data, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/githubWorkspaceProvider.js",
    "content": "import store from '../../store';\nimport githubHelper from './helpers/githubHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport userSvc from '../userSvc';\nimport gitWorkspaceSvc from '../gitWorkspaceSvc';\nimport badgeSvc from '../badgeSvc';\n\nconst getAbsolutePath = ({ id }) =>\n  `${store.getters['workspace/currentWorkspace'].path || ''}${id}`;\n\nexport default new Provider({\n  id: 'githubWorkspace',\n  name: 'GitHub',\n  getToken() {\n    return store.getters['workspace/syncToken'];\n  },\n  getWorkspaceParams({\n    owner,\n    repo,\n    branch,\n    path,\n  }) {\n    return {\n      providerId: this.id,\n      owner,\n      repo,\n      branch,\n      path,\n    };\n  },\n  getWorkspaceLocationUrl({\n    owner,\n    repo,\n    branch,\n    path,\n  }) {\n    return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;\n  },\n  getSyncDataUrl({ id }) {\n    const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];\n    return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`;\n  },\n  getSyncDataDescription({ id }) {\n    return getAbsolutePath({ id });\n  },\n  async initWorkspace() {\n    const { owner, repo, branch } = utils.queryParams;\n    const workspaceParams = this.getWorkspaceParams({ owner, repo, branch });\n    if (!branch) {\n      workspaceParams.branch = 'master';\n    }\n\n    // Extract path param\n    const path = (utils.queryParams.path || '')\n      .trim()\n      .replace(/^\\/*/, '') // Remove leading `/`\n      .replace(/\\/*$/, '/'); // Add trailing `/`\n    if (path !== '/') {\n      workspaceParams.path = path;\n    }\n\n    const workspaceId = utils.makeWorkspaceId(workspaceParams);\n    const workspace = store.getters['workspace/workspacesById'][workspaceId];\n\n    // See if we already have a token\n    let token;\n    if (workspace) {\n      // Token sub is in the workspace\n      token = store.getters['data/githubTokensBySub'][workspace.sub];\n    }\n    if (!token) {\n      await store.dispatch('modal/open', { type: 'githubAccount' });\n      token = await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);\n    }\n\n    if (!workspace) {\n      const pathEntries = (path || '').split('/');\n      const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/`\n      store.dispatch('workspace/patchWorkspacesById', {\n        [workspaceId]: {\n          ...workspaceParams,\n          id: workspaceId,\n          sub: token.sub,\n          name,\n        },\n      });\n    }\n\n    badgeSvc.addBadge('addGithubWorkspace');\n    return store.getters['workspace/workspacesById'][workspaceId];\n  },\n  getChanges() {\n    return githubHelper.getTree({\n      ...store.getters['workspace/currentWorkspace'],\n      token: this.getToken(),\n    });\n  },\n  prepareChanges(tree) {\n    return gitWorkspaceSvc.makeChanges(tree);\n  },\n  async saveWorkspaceItem({ item }) {\n    const syncData = {\n      id: store.getters.gitPathsByItemId[item.id],\n      type: item.type,\n      hash: item.hash,\n    };\n\n    // Files and folders are not in git, only contents\n    if (item.type === 'file' || item.type === 'folder') {\n      return { syncData };\n    }\n\n    // locations are stored as paths, so we upload an empty file\n    const syncToken = store.getters['workspace/syncToken'];\n    await githubHelper.uploadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token: syncToken,\n      path: getAbsolutePath(syncData),\n      content: '',\n      sha: gitWorkspaceSvc.shaByPath[syncData.id],\n    });\n\n    // Return sync data to save\n    return { syncData };\n  },\n  async removeWorkspaceItem({ syncData }) {\n    if (gitWorkspaceSvc.shaByPath[syncData.id]) {\n      const syncToken = store.getters['workspace/syncToken'];\n      await githubHelper.removeFile({\n        ...store.getters['workspace/currentWorkspace'],\n        token: syncToken,\n        path: getAbsolutePath(syncData),\n        sha: gitWorkspaceSvc.shaByPath[syncData.id],\n      });\n    }\n  },\n  async downloadWorkspaceContent({\n    token,\n    contentId,\n    contentSyncData,\n    fileSyncData,\n  }) {\n    const { sha, data } = await githubHelper.downloadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: getAbsolutePath(fileSyncData),\n    });\n    gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha;\n    const content = Provider.parseContent(data, contentId);\n    return {\n      content,\n      contentSyncData: {\n        ...contentSyncData,\n        hash: content.hash,\n        sha,\n      },\n    };\n  },\n  async downloadWorkspaceData({ token, syncData }) {\n    if (!syncData) {\n      return {};\n    }\n\n    const { sha, data } = await githubHelper.downloadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: getAbsolutePath(syncData),\n    });\n    gitWorkspaceSvc.shaByPath[syncData.id] = sha;\n    const item = JSON.parse(data);\n    return {\n      item,\n      syncData: {\n        ...syncData,\n        hash: item.hash,\n        sha,\n      },\n    };\n  },\n  async uploadWorkspaceContent({ token, content, file }) {\n    const path = store.getters.gitPathsByItemId[file.id];\n    const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;\n    const res = await githubHelper.uploadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: absolutePath,\n      content: Provider.serializeContent(content),\n      sha: gitWorkspaceSvc.shaByPath[path],\n    });\n\n    // Return new sync data\n    return {\n      contentSyncData: {\n        id: store.getters.gitPathsByItemId[content.id],\n        type: content.type,\n        hash: content.hash,\n        sha: res.content.sha,\n      },\n      fileSyncData: {\n        id: path,\n        type: 'file',\n        hash: file.hash,\n      },\n    };\n  },\n  async uploadWorkspaceData({ token, item }) {\n    const path = store.getters.gitPathsByItemId[item.id];\n    const syncData = {\n      id: path,\n      type: item.type,\n      hash: item.hash,\n    };\n    const res = await githubHelper.uploadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: getAbsolutePath(syncData),\n      content: JSON.stringify(item),\n      sha: gitWorkspaceSvc.shaByPath[path],\n    });\n\n    return {\n      syncData: {\n        ...syncData,\n        sha: res.content.sha,\n      },\n    };\n  },\n  async listFileRevisions({ token, fileSyncDataId }) {\n    const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];\n    const entries = await githubHelper.getCommits({\n      token,\n      owner,\n      repo,\n      sha: branch,\n      path: getAbsolutePath({ id: fileSyncDataId }),\n    });\n\n    return entries.map(({\n      author,\n      committer,\n      commit,\n      sha,\n    }) => {\n      let user;\n      if (author && author.login) {\n        user = author;\n      } else if (committer && committer.login) {\n        user = committer;\n      }\n      const sub = `${githubHelper.subPrefix}:${user.id}`;\n      userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url });\n      const date = (commit.author && commit.author.date)\n        || (commit.committer && commit.committer.date)\n        || 1;\n      return {\n        id: sha,\n        sub,\n        created: new Date(date).getTime(),\n      };\n    });\n  },\n  async loadFileRevision() {\n    // Revisions are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    fileSyncDataId,\n    revisionId,\n  }) {\n    const { data } = await githubHelper.downloadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      branch: revisionId,\n      path: getAbsolutePath({ id: fileSyncDataId }),\n    });\n    return Provider.parseContent(data, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/gitlabProvider.js",
    "content": "import store from '../../store';\nimport gitlabHelper from './helpers/gitlabHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport workspaceSvc from '../workspaceSvc';\nimport userSvc from '../userSvc';\n\nconst savedSha = {};\n\nexport default new Provider({\n  id: 'gitlab',\n  name: 'GitLab',\n  getToken({ sub }) {\n    return store.getters['data/gitlabTokensBySub'][sub];\n  },\n  getLocationUrl({\n    sub,\n    projectPath,\n    branch,\n    path,\n  }) {\n    const token = this.getToken({ sub });\n    return `${token.serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;\n  },\n  getLocationDescription({ path }) {\n    return path;\n  },\n  async downloadContent(token, syncLocation) {\n    const { sha, data } = await gitlabHelper.downloadFile({\n      ...syncLocation,\n      token,\n    });\n    savedSha[syncLocation.id] = sha;\n    return Provider.parseContent(data, `${syncLocation.fileId}/content`);\n  },\n  async uploadContent(token, content, syncLocation) {\n    const updatedSyncLocation = {\n      ...syncLocation,\n      projectId: await gitlabHelper.getProjectId(token, syncLocation),\n    };\n    if (!savedSha[updatedSyncLocation.id]) {\n      try {\n        // Get the last sha\n        await this.downloadContent(token, updatedSyncLocation);\n      } catch (e) {\n        // Ignore error\n      }\n    }\n    const sha = savedSha[updatedSyncLocation.id];\n    delete savedSha[updatedSyncLocation.id];\n    await gitlabHelper.uploadFile({\n      ...updatedSyncLocation,\n      token,\n      content: Provider.serializeContent(content),\n      sha,\n    });\n    return updatedSyncLocation;\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const updatedPublishLocation = {\n      ...publishLocation,\n      projectId: await gitlabHelper.getProjectId(token, publishLocation),\n    };\n    try {\n      // Get the last sha\n      await this.downloadContent(token, updatedPublishLocation);\n    } catch (e) {\n      // Ignore error\n    }\n    const sha = savedSha[updatedPublishLocation.id];\n    delete savedSha[updatedPublishLocation.id];\n    await gitlabHelper.uploadFile({\n      ...updatedPublishLocation,\n      token,\n      content: html,\n      sha,\n    });\n    return updatedPublishLocation;\n  },\n  async openFile(token, syncLocation) {\n    const updatedSyncLocation = {\n      ...syncLocation,\n      projectId: await gitlabHelper.getProjectId(token, syncLocation),\n    };\n\n    // Check if the file exists and open it\n    if (!Provider.openFileWithLocation(updatedSyncLocation)) {\n      // Download content from GitLab\n      let content;\n      try {\n        content = await this.downloadContent(token, updatedSyncLocation);\n      } catch (e) {\n        store.dispatch('notification/error', `Could not open file ${updatedSyncLocation.path}.`);\n        return;\n      }\n\n      // Create the file\n      let name = updatedSyncLocation.path;\n      const slashPos = name.lastIndexOf('/');\n      if (slashPos > -1 && slashPos < name.length - 1) {\n        name = name.slice(slashPos + 1);\n      }\n      const dotPos = name.lastIndexOf('.');\n      if (dotPos > 0 && slashPos < name.length) {\n        name = name.slice(0, dotPos);\n      }\n      const item = await workspaceSvc.createFile({\n        name,\n        parentId: store.getters['file/current'].parentId,\n        text: content.text,\n        properties: content.properties,\n        discussions: content.discussions,\n        comments: content.comments,\n      }, true);\n      store.commit('file/setCurrentId', item.id);\n      workspaceSvc.addSyncLocation({\n        ...updatedSyncLocation,\n        fileId: item.id,\n      });\n      store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitLab.`);\n    }\n  },\n  makeLocation(token, projectPath, branch, path) {\n    return {\n      providerId: this.id,\n      sub: token.sub,\n      projectPath,\n      branch,\n      path,\n    };\n  },\n  async listFileRevisions({ token, syncLocation }) {\n    const entries = await gitlabHelper.getCommits({\n      ...syncLocation,\n      token,\n    });\n\n    return entries.map((entry) => {\n      const email = entry.author_email || entry.committer_email;\n      const sub = `${gitlabHelper.subPrefix}:${token.serverUrl}/${email}`;\n      userSvc.addUserInfo({\n        id: sub,\n        name: entry.author_name || entry.committer_name,\n        imageUrl: '',\n      });\n      const date = entry.authored_date || entry.committed_date || 1;\n      return {\n        id: entry.id,\n        sub,\n        created: date ? new Date(date).getTime() : 1,\n      };\n    });\n  },\n  async loadFileRevision() {\n    // Revision are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    syncLocation,\n    revisionId,\n  }) {\n    const { data } = await gitlabHelper.downloadFile({\n      ...syncLocation,\n      token,\n      branch: revisionId,\n    });\n    return Provider.parseContent(data, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/gitlabWorkspaceProvider.js",
    "content": "import store from '../../store';\nimport gitlabHelper from './helpers/gitlabHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport userSvc from '../userSvc';\nimport gitWorkspaceSvc from '../gitWorkspaceSvc';\nimport badgeSvc from '../badgeSvc';\n\nconst getAbsolutePath = ({ id }) =>\n  `${store.getters['workspace/currentWorkspace'].path || ''}${id}`;\n\nexport default new Provider({\n  id: 'gitlabWorkspace',\n  name: 'GitLab',\n  getToken() {\n    return store.getters['workspace/syncToken'];\n  },\n  getWorkspaceParams({\n    serverUrl,\n    projectPath,\n    branch,\n    path,\n  }) {\n    return {\n      providerId: this.id,\n      serverUrl,\n      projectPath,\n      branch,\n      path,\n    };\n  },\n  getWorkspaceLocationUrl({\n    serverUrl,\n    projectPath,\n    branch,\n    path,\n  }) {\n    return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;\n  },\n  getSyncDataUrl({ id }) {\n    const { projectPath, branch } = store.getters['workspace/currentWorkspace'];\n    const { serverUrl } = this.getToken();\n    return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`;\n  },\n  getSyncDataDescription({ id }) {\n    return getAbsolutePath({ id });\n  },\n  async initWorkspace() {\n    const { serverUrl, branch } = utils.queryParams;\n    const workspaceParams = this.getWorkspaceParams({ serverUrl, branch });\n    if (!branch) {\n      workspaceParams.branch = 'master';\n    }\n\n    // Extract project path param\n    const projectPath = (utils.queryParams.projectPath || '')\n      .trim()\n      .replace(/^\\/*/, '') // Remove leading `/`\n      .replace(/\\/*$/, ''); // Remove trailing `/`\n    workspaceParams.projectPath = projectPath;\n\n    // Extract path param\n    const path = (utils.queryParams.path || '')\n      .trim()\n      .replace(/^\\/*/, '') // Remove leading `/`\n      .replace(/\\/*$/, '/'); // Add trailing `/`\n    if (path !== '/') {\n      workspaceParams.path = path;\n    }\n\n    const workspaceId = utils.makeWorkspaceId(workspaceParams);\n    const workspace = store.getters['workspace/workspacesById'][workspaceId];\n\n    // See if we already have a token\n    const sub = workspace ? workspace.sub : utils.queryParams.sub;\n    let token = store.getters['data/gitlabTokensBySub'][sub];\n    if (!token) {\n      const { applicationId } = await store.dispatch('modal/open', {\n        type: 'gitlabAccount',\n        forceServerUrl: serverUrl,\n      });\n      token = await gitlabHelper.addAccount(serverUrl, applicationId, sub);\n    }\n\n    if (!workspace) {\n      const projectId = await gitlabHelper.getProjectId(token, workspaceParams);\n      const pathEntries = (path || '').split('/');\n      const projectPathEntries = (projectPath || '').split('/');\n      const name = pathEntries[pathEntries.length - 2] // path ends with `/`\n        || projectPathEntries[projectPathEntries.length - 1];\n      store.dispatch('workspace/patchWorkspacesById', {\n        [workspaceId]: {\n          ...workspaceParams,\n          projectId,\n          id: workspaceId,\n          sub: token.sub,\n          name,\n        },\n      });\n    }\n\n    badgeSvc.addBadge('addGitlabWorkspace');\n    return store.getters['workspace/workspacesById'][workspaceId];\n  },\n  getChanges() {\n    return gitlabHelper.getTree({\n      ...store.getters['workspace/currentWorkspace'],\n      token: this.getToken(),\n    });\n  },\n  prepareChanges(tree) {\n    return gitWorkspaceSvc.makeChanges(tree.map(entry => ({\n      ...entry,\n      sha: entry.id,\n    })));\n  },\n  async saveWorkspaceItem({ item }) {\n    const syncData = {\n      id: store.getters.gitPathsByItemId[item.id],\n      type: item.type,\n      hash: item.hash,\n    };\n\n    // Files and folders are not in git, only contents\n    if (item.type === 'file' || item.type === 'folder') {\n      return { syncData };\n    }\n\n    // locations are stored as paths, so we upload an empty file\n    const syncToken = store.getters['workspace/syncToken'];\n    await gitlabHelper.uploadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token: syncToken,\n      path: getAbsolutePath(syncData),\n      content: '',\n      sha: gitWorkspaceSvc.shaByPath[syncData.id],\n    });\n\n    // Return sync data to save\n    return { syncData };\n  },\n  async removeWorkspaceItem({ syncData }) {\n    if (gitWorkspaceSvc.shaByPath[syncData.id]) {\n      const syncToken = store.getters['workspace/syncToken'];\n      await gitlabHelper.removeFile({\n        ...store.getters['workspace/currentWorkspace'],\n        token: syncToken,\n        path: getAbsolutePath(syncData),\n        sha: gitWorkspaceSvc.shaByPath[syncData.id],\n      });\n    }\n  },\n  async downloadWorkspaceContent({\n    token,\n    contentId,\n    contentSyncData,\n    fileSyncData,\n  }) {\n    const { sha, data } = await gitlabHelper.downloadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: getAbsolutePath(fileSyncData),\n    });\n    gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha;\n    const content = Provider.parseContent(data, contentId);\n    return {\n      content,\n      contentSyncData: {\n        ...contentSyncData,\n        hash: content.hash,\n        sha,\n      },\n    };\n  },\n  async downloadWorkspaceData({ token, syncData }) {\n    if (!syncData) {\n      return {};\n    }\n\n    const { sha, data } = await gitlabHelper.downloadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: getAbsolutePath(syncData),\n    });\n    gitWorkspaceSvc.shaByPath[syncData.id] = sha;\n    const item = JSON.parse(data);\n    return {\n      item,\n      syncData: {\n        ...syncData,\n        hash: item.hash,\n        sha,\n      },\n    };\n  },\n  async uploadWorkspaceContent({ token, content, file }) {\n    const path = store.getters.gitPathsByItemId[file.id];\n    const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;\n    const sha = gitWorkspaceSvc.shaByPath[path];\n    await gitlabHelper.uploadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: absolutePath,\n      content: Provider.serializeContent(content),\n      sha,\n    });\n\n    // Return new sync data\n    return {\n      contentSyncData: {\n        id: store.getters.gitPathsByItemId[content.id],\n        type: content.type,\n        hash: content.hash,\n        sha,\n      },\n      fileSyncData: {\n        id: path,\n        type: 'file',\n        hash: file.hash,\n      },\n    };\n  },\n  async uploadWorkspaceData({ token, item }) {\n    const path = store.getters.gitPathsByItemId[item.id];\n    const syncData = {\n      id: path,\n      type: item.type,\n      hash: item.hash,\n    };\n    const res = await gitlabHelper.uploadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      path: getAbsolutePath(syncData),\n      content: JSON.stringify(item),\n      sha: gitWorkspaceSvc.shaByPath[path],\n    });\n\n    return {\n      syncData: {\n        ...syncData,\n        sha: res.content.sha,\n      },\n    };\n  },\n  async listFileRevisions({ token, fileSyncDataId }) {\n    const { projectId, branch } = store.getters['workspace/currentWorkspace'];\n    const entries = await gitlabHelper.getCommits({\n      token,\n      projectId,\n      sha: branch,\n      path: getAbsolutePath({ id: fileSyncDataId }),\n    });\n\n    return entries.map((entry) => {\n      const email = entry.author_email || entry.committer_email;\n      const sub = `${gitlabHelper.subPrefix}:${token.serverUrl}/${email}`;\n      userSvc.addUserInfo({\n        id: sub,\n        name: entry.author_name || entry.committer_name,\n        imageUrl: '', // No way to get user's avatar url...\n      });\n      const date = entry.authored_date || entry.committed_date || 1;\n      return {\n        id: entry.id,\n        sub,\n        created: date ? new Date(date).getTime() : 1,\n      };\n    });\n  },\n  async loadFileRevision() {\n    // Revisions are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    fileSyncDataId,\n    revisionId,\n  }) {\n    const { data } = await gitlabHelper.downloadFile({\n      ...store.getters['workspace/currentWorkspace'],\n      token,\n      branch: revisionId,\n      path: getAbsolutePath({ id: fileSyncDataId }),\n    });\n    return Provider.parseContent(data, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/googleDriveAppDataProvider.js",
    "content": "import store from '../../store';\nimport googleHelper from './helpers/googleHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\n\nlet syncStartPageToken;\n\nexport default new Provider({\n  id: 'googleDriveAppData',\n  name: 'Google Drive app data',\n  getToken() {\n    return store.getters['workspace/syncToken'];\n  },\n  getWorkspaceParams() {\n    // No param as it's the main workspace\n    return {};\n  },\n  getWorkspaceLocationUrl() {\n    // No direct link to app data\n    return null;\n  },\n  getSyncDataUrl() {\n    // No direct link to app data\n    return null;\n  },\n  getSyncDataDescription({ id }) {\n    return id;\n  },\n  async initWorkspace() {\n    // Nothing much to do since the main workspace isn't necessarily synchronized\n    // Return the main workspace\n    return store.getters['workspace/workspacesById'].main;\n  },\n  async getChanges() {\n    const syncToken = store.getters['workspace/syncToken'];\n    const startPageToken = store.getters['data/localSettings'].syncStartPageToken;\n    const result = await googleHelper.getChanges(syncToken, startPageToken, true);\n    const changes = result.changes.filter((change) => {\n      if (change.file) {\n        // Parse item from file name\n        try {\n          change.item = JSON.parse(change.file.name);\n        } catch (e) {\n          return false;\n        }\n        // Build sync data\n        change.syncData = {\n          id: change.fileId,\n          itemId: change.item.id,\n          type: change.item.type,\n          hash: change.item.hash,\n        };\n      }\n      change.syncDataId = change.fileId;\n      return true;\n    });\n    syncStartPageToken = result.startPageToken;\n    return changes;\n  },\n  onChangesApplied() {\n    store.dispatch('data/patchLocalSettings', {\n      syncStartPageToken,\n    });\n  },\n  async saveWorkspaceItem({ item, syncData, ifNotTooLate }) {\n    const syncToken = store.getters['workspace/syncToken'];\n    const file = await googleHelper.uploadAppDataFile({\n      token: syncToken,\n      name: JSON.stringify(item),\n      fileId: syncData && syncData.id,\n      ifNotTooLate,\n    });\n\n    // Build sync data to save\n    return {\n      syncData: {\n        id: file.id,\n        itemId: item.id,\n        type: item.type,\n        hash: item.hash,\n      },\n    };\n  },\n  removeWorkspaceItem({ syncData, ifNotTooLate }) {\n    const syncToken = store.getters['workspace/syncToken'];\n    return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate);\n  },\n  async downloadWorkspaceContent({ token, contentSyncData }) {\n    const data = await googleHelper.downloadAppDataFile(token, contentSyncData.id);\n    const content = utils.addItemHash(JSON.parse(data));\n    return {\n      content,\n      contentSyncData: {\n        ...contentSyncData,\n        hash: content.hash,\n      },\n    };\n  },\n  async downloadWorkspaceData({ token, syncData }) {\n    if (!syncData) {\n      return {};\n    }\n\n    const data = await googleHelper.downloadAppDataFile(token, syncData.id);\n    const item = utils.addItemHash(JSON.parse(data));\n    return {\n      item,\n      syncData: {\n        ...syncData,\n        hash: item.hash,\n      },\n    };\n  },\n  async uploadWorkspaceContent({\n    token,\n    content,\n    contentSyncData,\n    ifNotTooLate,\n  }) {\n    const gdriveFile = await googleHelper.uploadAppDataFile({\n      token,\n      name: JSON.stringify({\n        id: content.id,\n        type: content.type,\n        hash: content.hash,\n      }),\n      media: JSON.stringify(content),\n      fileId: contentSyncData && contentSyncData.id,\n      ifNotTooLate,\n    });\n\n    // Return new sync data\n    return {\n      contentSyncData: {\n        id: gdriveFile.id,\n        itemId: content.id,\n        type: content.type,\n        hash: content.hash,\n      },\n    };\n  },\n  async uploadWorkspaceData({\n    token,\n    item,\n    syncData,\n    ifNotTooLate,\n  }) {\n    const file = await googleHelper.uploadAppDataFile({\n      token,\n      name: JSON.stringify({\n        id: item.id,\n        type: item.type,\n        hash: item.hash,\n      }),\n      media: JSON.stringify(item),\n      fileId: syncData && syncData.id,\n      ifNotTooLate,\n    });\n\n    // Return new sync data\n    return {\n      syncData: {\n        id: file.id,\n        itemId: item.id,\n        type: item.type,\n        hash: item.hash,\n      },\n    };\n  },\n  async listFileRevisions({ token, contentSyncDataId }) {\n    const revisions = await googleHelper.getAppDataFileRevisions(token, contentSyncDataId);\n    return revisions.map(revision => ({\n      id: revision.id,\n      sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`,\n      created: new Date(revision.modifiedTime).getTime(),\n    }));\n  },\n  async loadFileRevision() {\n    // Revisions are already loaded\n    return false;\n  },\n  async getFileRevisionContent({ token, contentSyncDataId, revisionId }) {\n    const content = await googleHelper\n      .downloadAppDataFileRevision(token, contentSyncDataId, revisionId);\n    return JSON.parse(content);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/googleDriveProvider.js",
    "content": "import store from '../../store';\nimport googleHelper from './helpers/googleHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport workspaceSvc from '../workspaceSvc';\n\nexport default new Provider({\n  id: 'googleDrive',\n  name: 'Google Drive',\n  getToken({ sub }) {\n    const token = store.getters['data/googleTokensBySub'][sub];\n    return token && token.isDrive ? token : null;\n  },\n  getLocationUrl({ driveFileId }) {\n    return `https://docs.google.com/file/d/${driveFileId}/edit`;\n  },\n  getLocationDescription({ driveFileId }) {\n    return driveFileId;\n  },\n  async initAction() {\n    const state = googleHelper.driveState || {};\n    if (state.userId) {\n      // Try to find the token corresponding to the user ID\n      let token = store.getters['data/googleTokensBySub'][state.userId];\n      // If not found or not enough permission, popup an OAuth2 window\n      if (!token || !token.isDrive) {\n        await store.dispatch('modal/open', { type: 'googleDriveAccount' });\n        token = await googleHelper.addDriveAccount(\n          !store.getters['data/localSettings'].googleDriveRestrictedAccess,\n          state.userId,\n        );\n      }\n\n      const openWorkspaceIfExists = (file) => {\n        const folderId = file\n          && file.appProperties\n          && file.appProperties.folderId;\n        if (folderId) {\n          // See if we have the corresponding workspace\n          const workspaceParams = {\n            providerId: 'googleDriveWorkspace',\n            folderId,\n          };\n          const workspaceId = utils.makeWorkspaceId(workspaceParams);\n          const workspace = store.getters['workspace/workspacesById'][workspaceId];\n          // If we have the workspace, open it by changing the current URL\n          if (workspace) {\n            utils.setQueryParams(workspaceParams);\n          }\n        }\n      };\n\n      switch (state.action) {\n        case 'create':\n        default:\n          // See if folder is part of a workspace we can open\n          try {\n            const folder = await googleHelper.getFile(token, state.folderId);\n            folder.appProperties = folder.appProperties || {};\n            googleHelper.driveActionFolder = folder;\n            openWorkspaceIfExists(folder);\n          } catch (err) {\n            if (!err || err.status !== 404) {\n              throw err;\n            }\n            // We received an HTTP 404 meaning we have no permission to read the folder\n            googleHelper.driveActionFolder = { id: state.folderId };\n          }\n          break;\n\n        case 'open': {\n          await utils.awaitSequence(state.ids || [], async (id) => {\n            const file = await googleHelper.getFile(token, id);\n            file.appProperties = file.appProperties || {};\n            googleHelper.driveActionFiles.push(file);\n          });\n\n          // Check if first file is part of a workspace\n          openWorkspaceIfExists(googleHelper.driveActionFiles[0]);\n        }\n      }\n    }\n  },\n  async performAction() {\n    const state = googleHelper.driveState || {};\n    const token = store.getters['data/googleTokensBySub'][state.userId];\n    switch (token && state.action) {\n      case 'create': {\n        const file = await workspaceSvc.createFile({}, true);\n        store.commit('file/setCurrentId', file.id);\n        // Return a new syncLocation\n        return this.makeLocation(token, null, googleHelper.driveActionFolder.id);\n      }\n      case 'open':\n        store.dispatch(\n          'queue/enqueue',\n          () => this.openFiles(token, googleHelper.driveActionFiles),\n        );\n        return null;\n      default:\n        return null;\n    }\n  },\n  async downloadContent(token, syncLocation) {\n    const content = await googleHelper.downloadFile(token, syncLocation.driveFileId);\n    return Provider.parseContent(content, `${syncLocation.fileId}/content`);\n  },\n  async uploadContent(token, content, syncLocation, ifNotTooLate) {\n    const file = store.state.file.itemsById[syncLocation.fileId];\n    const name = utils.sanitizeName(file && file.name);\n    const parents = [];\n    if (syncLocation.driveParentId) {\n      parents.push(syncLocation.driveParentId);\n    }\n    const driveFile = await googleHelper.uploadFile({\n      token,\n      name,\n      parents,\n      media: Provider.serializeContent(content),\n      fileId: syncLocation.driveFileId,\n      ifNotTooLate,\n    });\n    return {\n      ...syncLocation,\n      driveFileId: driveFile.id,\n    };\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const driveFile = await googleHelper.uploadFile({\n      token,\n      name: metadata.title,\n      parents: [],\n      media: html,\n      mediaType: publishLocation.templateId ? 'text/html' : undefined,\n      fileId: publishLocation.driveFileId,\n    });\n    return {\n      ...publishLocation,\n      driveFileId: driveFile.id,\n    };\n  },\n  async openFiles(token, driveFiles) {\n    return utils.awaitSequence(driveFiles, async (driveFile) => {\n      // Check if the file exists and open it\n      if (!Provider.openFileWithLocation({\n        providerId: this.id,\n        driveFileId: driveFile.id,\n      })) {\n        // Download content from Google Drive\n        const syncLocation = {\n          driveFileId: driveFile.id,\n          providerId: this.id,\n          sub: token.sub,\n        };\n        let content;\n        try {\n          content = await this.downloadContent(token, syncLocation);\n        } catch (e) {\n          store.dispatch('notification/error', `Could not open file ${driveFile.id}.`);\n          return;\n        }\n\n        // Create the file\n        const item = await workspaceSvc.createFile({\n          name: driveFile.name,\n          parentId: store.getters['file/current'].parentId,\n          text: content.text,\n          properties: content.properties,\n          discussions: content.discussions,\n          comments: content.comments,\n        }, true);\n        store.commit('file/setCurrentId', item.id);\n        workspaceSvc.addSyncLocation({\n          ...syncLocation,\n          fileId: item.id,\n        });\n        store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`);\n      }\n    });\n  },\n  makeLocation(token, fileId, folderId) {\n    const location = {\n      providerId: this.id,\n      sub: token.sub,\n    };\n    if (fileId) {\n      location.driveFileId = fileId;\n    }\n    if (folderId) {\n      location.driveParentId = folderId;\n    }\n    return location;\n  },\n  async listFileRevisions({ token, syncLocation }) {\n    const revisions = await googleHelper.getFileRevisions(token, syncLocation.driveFileId);\n    return revisions.map(revision => ({\n      id: revision.id,\n      sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`,\n      created: new Date(revision.modifiedTime).getTime(),\n    }));\n  },\n  async loadFileRevision() {\n    // Revision are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    syncLocation,\n    revisionId,\n  }) {\n    const content = await googleHelper\n      .downloadFileRevision(token, syncLocation.driveFileId, revisionId);\n    return Provider.parseContent(content, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/googleDriveWorkspaceProvider.js",
    "content": "import store from '../../store';\nimport googleHelper from './helpers/googleHelper';\nimport Provider from './common/Provider';\nimport utils from '../utils';\nimport workspaceSvc from '../workspaceSvc';\nimport badgeSvc from '../badgeSvc';\n\nlet fileIdToOpen;\nlet syncStartPageToken;\n\nexport default new Provider({\n  id: 'googleDriveWorkspace',\n  name: 'Google Drive',\n  getToken() {\n    return store.getters['workspace/syncToken'];\n  },\n  getWorkspaceParams({ folderId }) {\n    return {\n      providerId: this.id,\n      folderId,\n    };\n  },\n  getWorkspaceLocationUrl({ folderId }) {\n    return `https://docs.google.com/folder/d/${folderId}`;\n  },\n  getSyncDataUrl({ id }) {\n    return `https://docs.google.com/file/d/${id}/edit`;\n  },\n  getSyncDataDescription({ id }) {\n    return id;\n  },\n  async initWorkspace() {\n    const makeWorkspaceId = folderId => folderId\n      && utils.makeWorkspaceId(this.getWorkspaceParams({ folderId }));\n\n    const getWorkspace = folderId =>\n      store.getters['workspace/workspacesById'][makeWorkspaceId(folderId)];\n\n    const initFolder = async (token, folder) => {\n      const appProperties = {\n        folderId: folder.id,\n        dataFolderId: folder.appProperties.dataFolderId,\n        trashFolderId: folder.appProperties.trashFolderId,\n      };\n\n      // Make sure data folder exists\n      if (!appProperties.dataFolderId) {\n        const dataFolder = await googleHelper.uploadFile({\n          token,\n          name: '.stackedit-data',\n          parents: [folder.id],\n          appProperties: { folderId: folder.id },\n          mediaType: googleHelper.folderMimeType,\n        });\n        appProperties.dataFolderId = dataFolder.id;\n      }\n\n      // Make sure trash folder exists\n      if (!appProperties.trashFolderId) {\n        const trashFolder = await googleHelper.uploadFile({\n          token,\n          name: '.stackedit-trash',\n          parents: [folder.id],\n          appProperties: { folderId: folder.id },\n          mediaType: googleHelper.folderMimeType,\n        });\n        appProperties.trashFolderId = trashFolder.id;\n      }\n\n      // Update workspace if some properties are missing\n      if (appProperties.folderId !== folder.appProperties.folderId\n        || appProperties.dataFolderId !== folder.appProperties.dataFolderId\n        || appProperties.trashFolderId !== folder.appProperties.trashFolderId\n      ) {\n        await googleHelper.uploadFile({\n          token,\n          appProperties,\n          mediaType: googleHelper.folderMimeType,\n          fileId: folder.id,\n        });\n      }\n\n      // Update workspace in the store\n      const workspaceId = makeWorkspaceId(folder.id);\n      store.dispatch('workspace/patchWorkspacesById', {\n        [workspaceId]: {\n          id: workspaceId,\n          sub: token.sub,\n          name: folder.name,\n          providerId: this.id,\n          folderId: folder.id,\n          teamDriveId: folder.teamDriveId,\n          dataFolderId: appProperties.dataFolderId,\n          trashFolderId: appProperties.trashFolderId,\n        },\n      });\n    };\n\n    // Token sub is in the workspace or in the url if workspace is about to be created\n    const { sub } = getWorkspace(utils.queryParams.folderId) || utils.queryParams;\n    // See if we already have a token\n    let token = store.getters['data/googleTokensBySub'][sub];\n    // If no token has been found, popup an authorize window and get one\n    if (!token || !token.isDrive || !token.driveFullAccess) {\n      await store.dispatch('modal/open', 'workspaceGoogleRedirection');\n      token = await googleHelper.addDriveAccount(true, utils.queryParams.sub);\n    }\n\n    let { folderId } = utils.queryParams;\n    // If no folderId is provided, create one\n    if (!folderId) {\n      const folder = await googleHelper.uploadFile({\n        token,\n        name: 'StackEdit workspace',\n        parents: [],\n        mediaType: googleHelper.folderMimeType,\n      });\n      await initFolder(token, {\n        ...folder,\n        appProperties: {},\n      });\n      folderId = folder.id;\n    }\n\n    // Init workspace\n    if (!getWorkspace(folderId)) {\n      let folder;\n      try {\n        folder = await googleHelper.getFile(token, folderId);\n      } catch (err) {\n        throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`);\n      }\n      folder.appProperties = folder.appProperties || {};\n      const folderIdProperty = folder.appProperties.folderId;\n      if (folderIdProperty && folderIdProperty !== folderId) {\n        throw new Error(`Folder ${folderId} is part of another workspace.`);\n      }\n      await initFolder(token, folder);\n    }\n\n    badgeSvc.addBadge('addGoogleDriveWorkspace');\n    return getWorkspace(folderId);\n  },\n  async performAction() {\n    const state = googleHelper.driveState || {};\n    const token = this.getToken();\n    switch (token && state.action) {\n      case 'create': {\n        const driveFolder = googleHelper.driveActionFolder;\n        let syncData = store.getters['data/syncDataById'][driveFolder.id];\n        if (!syncData && driveFolder.appProperties.id) {\n          // Create folder if not already synced\n          store.commit('folder/setItem', {\n            id: driveFolder.appProperties.id,\n            name: driveFolder.name,\n          });\n          const item = store.state.folder.itemsById[driveFolder.appProperties.id];\n          syncData = {\n            id: driveFolder.id,\n            itemId: item.id,\n            type: item.type,\n            hash: item.hash,\n          };\n          store.dispatch('data/patchSyncDataById', {\n            [syncData.id]: syncData,\n          });\n        }\n        const file = await workspaceSvc.createFile({\n          parentId: syncData && syncData.itemId,\n        }, true);\n        store.commit('file/setCurrentId', file.id);\n        // File will be created on next workspace sync\n        break;\n      }\n      case 'open': {\n        // open first file only\n        const firstFile = googleHelper.driveActionFiles[0];\n        const syncData = store.getters['data/syncDataById'][firstFile.id];\n        if (!syncData) {\n          fileIdToOpen = firstFile.id;\n        } else {\n          store.commit('file/setCurrentId', syncData.itemId);\n        }\n        break;\n      }\n      default:\n    }\n  },\n  async getChanges() {\n    const workspace = store.getters['workspace/currentWorkspace'];\n    const syncToken = store.getters['workspace/syncToken'];\n    const lastStartPageToken = store.getters['data/localSettings'].syncStartPageToken;\n    const { changes, startPageToken } = await googleHelper\n      .getChanges(syncToken, lastStartPageToken, false, workspace.teamDriveId);\n\n    syncStartPageToken = startPageToken;\n    return changes;\n  },\n  prepareChanges(changes) {\n    // Collect possible parent IDs\n    const parentIds = {};\n    Object.entries(store.getters['data/syncDataByItemId']).forEach(([id, syncData]) => {\n      parentIds[syncData.id] = id;\n    });\n    changes.forEach((change) => {\n      const { id } = (change.file || {}).appProperties || {};\n      if (id) {\n        parentIds[change.fileId] = id;\n      }\n    });\n\n    // Collect changes\n    const workspace = store.getters['workspace/currentWorkspace'];\n    const result = [];\n    changes.forEach((change) => {\n      // Ignore changes on StackEdit own folders\n      if (change.fileId === workspace.folderId\n        || change.fileId === workspace.dataFolderId\n        || change.fileId === workspace.trashFolderId\n      ) {\n        return;\n      }\n\n      let contentChange;\n      if (change.file) {\n        // Ignore changes in files that are not in the workspace\n        const { appProperties } = change.file;\n        if (!appProperties || appProperties.folderId !== workspace.folderId\n        ) {\n          return;\n        }\n\n        // If change is on a data item\n        if (change.file.parents[0] === workspace.dataFolderId) {\n          // Data item has a JSON filename\n          try {\n            change.item = JSON.parse(change.file.name);\n          } catch (e) {\n            return;\n          }\n        } else {\n          // Change on a file or folder\n          const type = change.file.mimeType === googleHelper.folderMimeType\n            ? 'folder'\n            : 'file';\n          const item = {\n            id: appProperties.id,\n            type,\n            name: change.file.name,\n            parentId: null,\n          };\n\n          // Fill parentId\n          if (change.file.parents.some(parentId => parentId === workspace.trashFolderId)) {\n            item.parentId = 'trash';\n          } else {\n            change.file.parents.some((parentId) => {\n              if (!parentIds[parentId]) {\n                return false;\n              }\n              item.parentId = parentIds[parentId];\n              return true;\n            });\n          }\n          change.item = utils.addItemHash(item);\n\n          if (type === 'file') {\n            // create a fake change as a file content change\n            const id = `${appProperties.id}/content`;\n            const syncDataId = `${change.fileId}/content`;\n            contentChange = {\n              item: {\n                id,\n                type: 'content',\n                // Need a truthy value to force saving sync data\n                hash: 1,\n              },\n              syncData: {\n                id: syncDataId,\n                itemId: id,\n                type: 'content',\n                // Need a truthy value to force downloading the content\n                hash: 1,\n              },\n              syncDataId,\n            };\n          }\n        }\n\n        // Build sync data\n        change.syncData = {\n          id: change.fileId,\n          parentIds: change.file.parents,\n          itemId: change.item.id,\n          type: change.item.type,\n          hash: change.item.hash,\n        };\n      } else {\n        // Item was removed\n        const syncData = store.getters['data/syncDataById'][change.fileId];\n        if (syncData && syncData.type === 'file') {\n          // create a fake change as a file content change\n          contentChange = {\n            syncDataId: `${change.fileId}/content`,\n          };\n        }\n      }\n\n      // Push change\n      change.syncDataId = change.fileId;\n      result.push(change);\n      if (contentChange) {\n        result.push(contentChange);\n      }\n    });\n\n    return result;\n  },\n  onChangesApplied() {\n    store.dispatch('data/patchLocalSettings', {\n      syncStartPageToken,\n    });\n  },\n  async saveWorkspaceItem({ item, syncData, ifNotTooLate }) {\n    const workspace = store.getters['workspace/currentWorkspace'];\n    const syncToken = store.getters['workspace/syncToken'];\n    let file;\n    if (item.type !== 'file' && item.type !== 'folder') {\n      // For sync/publish locations, store item as filename\n      file = await googleHelper.uploadFile({\n        token: syncToken,\n        name: JSON.stringify(item),\n        parents: [workspace.dataFolderId],\n        appProperties: {\n          folderId: workspace.folderId,\n        },\n        fileId: syncData && syncData.id,\n        oldParents: syncData && syncData.parentIds,\n        ifNotTooLate,\n      });\n    } else {\n      // For type `file` or `folder`\n      const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId];\n      let parentId;\n      if (item.parentId === 'trash') {\n        parentId = workspace.trashFolderId;\n      } else if (parentSyncData) {\n        parentId = parentSyncData.id;\n      } else {\n        parentId = workspace.folderId;\n      }\n\n      file = await googleHelper.uploadFile({\n        token: syncToken,\n        name: item.name,\n        parents: [parentId],\n        appProperties: {\n          id: item.id,\n          folderId: workspace.folderId,\n        },\n        mediaType: item.type === 'folder' ? googleHelper.folderMimeType : undefined,\n        fileId: syncData && syncData.id,\n        oldParents: syncData && syncData.parentIds,\n        ifNotTooLate,\n      });\n    }\n\n    // Build sync data to save\n    return {\n      syncData: {\n        id: file.id,\n        parentIds: file.parents,\n        itemId: item.id,\n        type: item.type,\n        hash: item.hash,\n      },\n    };\n  },\n  async removeWorkspaceItem({ syncData, ifNotTooLate }) {\n    // Ignore content deletion\n    if (syncData.type !== 'content') {\n      const syncToken = store.getters['workspace/syncToken'];\n      await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate);\n    }\n  },\n  async downloadWorkspaceContent({ token, contentSyncData, fileSyncData }) {\n    const data = await googleHelper.downloadFile(token, fileSyncData.id);\n    const content = Provider.parseContent(data, contentSyncData.itemId);\n\n    // Open the file requested by action if it wasn't synced yet\n    if (fileIdToOpen && fileIdToOpen === fileSyncData.id) {\n      fileIdToOpen = null;\n      // Open the file once downloaded content has been stored\n      setTimeout(() => {\n        store.commit('file/setCurrentId', fileSyncData.itemId);\n      }, 10);\n    }\n\n    return {\n      content,\n      contentSyncData: {\n        ...contentSyncData,\n        hash: content.hash,\n      },\n    };\n  },\n  async downloadWorkspaceData({ token, syncData }) {\n    if (!syncData) {\n      return {};\n    }\n\n    const content = await googleHelper.downloadFile(token, syncData.id);\n    const item = JSON.parse(content);\n    return {\n      item,\n      syncData: {\n        ...syncData,\n        hash: item.hash,\n      },\n    };\n  },\n  async uploadWorkspaceContent({\n    token,\n    content,\n    file,\n    fileSyncData,\n    ifNotTooLate,\n  }) {\n    let gdriveFile;\n    let newFileSyncData;\n\n    if (fileSyncData) {\n      // Only update file media\n      gdriveFile = await googleHelper.uploadFile({\n        token,\n        media: Provider.serializeContent(content),\n        fileId: fileSyncData.id,\n        ifNotTooLate,\n      });\n    } else {\n      // Create file with media\n      const workspace = store.getters['workspace/currentWorkspace'];\n      const parentSyncData = store.getters['data/syncDataByItemId'][file.parentId];\n      gdriveFile = await googleHelper.uploadFile({\n        token,\n        name: file.name,\n        parents: [parentSyncData ? parentSyncData.id : workspace.folderId],\n        appProperties: {\n          id: file.id,\n          folderId: workspace.folderId,\n        },\n        media: Provider.serializeContent(content),\n        ifNotTooLate,\n      });\n\n      // Create file sync data\n      newFileSyncData = {\n        id: gdriveFile.id,\n        parentIds: gdriveFile.parents,\n        itemId: file.id,\n        type: file.type,\n        hash: file.hash,\n      };\n    }\n\n    // Return new sync data\n    return {\n      contentSyncData: {\n        id: `${gdriveFile.id}/content`,\n        itemId: content.id,\n        type: content.type,\n        hash: content.hash,\n      },\n      fileSyncData: newFileSyncData,\n    };\n  },\n  async uploadWorkspaceData({\n    token,\n    item,\n    syncData,\n    ifNotTooLate,\n  }) {\n    const workspace = store.getters['workspace/currentWorkspace'];\n    const file = await googleHelper.uploadFile({\n      token,\n      name: JSON.stringify({\n        id: item.id,\n        type: item.type,\n        hash: item.hash,\n      }),\n      parents: [workspace.dataFolderId],\n      appProperties: {\n        folderId: workspace.folderId,\n      },\n      media: JSON.stringify(item),\n      mediaType: 'application/json',\n      fileId: syncData && syncData.id,\n      oldParents: syncData && syncData.parentIds,\n      ifNotTooLate,\n    });\n\n    // Return new sync data\n    return {\n      syncData: {\n        id: file.id,\n        parentIds: file.parents,\n        itemId: item.id,\n        type: item.type,\n        hash: item.hash,\n      },\n    };\n  },\n  async listFileRevisions({ token, fileSyncDataId }) {\n    const revisions = await googleHelper.getFileRevisions(token, fileSyncDataId);\n    return revisions.map(revision => ({\n      id: revision.id,\n      sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`,\n      created: new Date(revision.modifiedTime).getTime(),\n    }));\n  },\n  async loadFileRevision() {\n    // Revision are already loaded\n    return false;\n  },\n  async getFileRevisionContent({\n    token,\n    contentId,\n    fileSyncDataId,\n    revisionId,\n  }) {\n    const content = await googleHelper.downloadFileRevision(token, fileSyncDataId, revisionId);\n    return Provider.parseContent(content, contentId);\n  },\n});\n"
  },
  {
    "path": "src/services/providers/helpers/couchdbHelper.js",
    "content": "import networkSvc from '../../networkSvc';\nimport utils from '../../utils';\nimport store from '../../../store';\nimport userSvc from '../../userSvc';\n\nconst request = async (token, options = {}) => {\n  const baseUrl = `${token.dbUrl}/`;\n  const getLastToken = () => store.getters['data/couchdbTokensBySub'][token.sub];\n\n  const assertUnauthorized = (err) => {\n    if (err.status !== 401) {\n      throw err;\n    }\n  };\n\n  const onUnauthorized = async () => {\n    try {\n      const { name, password } = getLastToken();\n      await networkSvc.request({\n        method: 'POST',\n        url: utils.resolveUrl(baseUrl, '../_session'),\n        withCredentials: true,\n        body: {\n          name,\n          password,\n        },\n      });\n    } catch (err) {\n      assertUnauthorized(err);\n      await store.dispatch('modal/open', {\n        type: 'couchdbCredentials',\n        token: getLastToken(),\n      });\n      await onUnauthorized();\n    }\n  };\n\n  const config = {\n    ...options,\n    headers: {\n      Accept: 'application/json',\n      ...options.headers || {},\n    },\n    url: utils.resolveUrl(baseUrl, options.path || '.'),\n    withCredentials: true,\n  };\n\n  try {\n    let res;\n    try {\n      res = await networkSvc.request(config);\n    } catch (err) {\n      assertUnauthorized(err);\n      await onUnauthorized();\n      res = await networkSvc.request(config);\n    }\n    return res.body;\n  } catch (err) {\n    if (err.status === 409) {\n      throw new Error('TOO_LATE');\n    }\n    throw err;\n  }\n};\n\nexport default {\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/database/common.html#db\n   */\n  getDb(token) {\n    return request(token);\n  },\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/database/changes.html#db-changes\n   */\n  async getChanges(token, lastSeq) {\n    const result = {\n      changes: [],\n      lastSeq,\n    };\n\n    const getPage = async () => {\n      const body = await request(token, {\n        method: 'GET',\n        path: '_changes',\n        params: {\n          since: result.lastSeq || 0,\n          include_docs: true,\n          limit: 1000,\n        },\n      });\n      result.changes = [...result.changes, ...body.results];\n      result.lastSeq = body.last_seq;\n      if (body.pending) {\n        return getPage();\n      }\n      return result;\n    };\n\n    return getPage();\n  },\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/database/common.html#post--db\n   * http://docs.couchdb.org/en/2.1.1/api/document/common.html#put--db-docid\n   */\n  async uploadDocument({\n    token,\n    item,\n    data = null,\n    dataType = null,\n    documentId = null,\n    rev = null,\n  }) {\n    const options = {\n      method: 'POST',\n      body: { item, time: Date.now() },\n    };\n    const userId = userSvc.getCurrentUserId();\n    if (userId) {\n      options.body.sub = userId;\n    }\n    if (documentId) {\n      options.method = 'PUT';\n      options.path = documentId;\n      options.body._rev = rev; // eslint-disable-line no-underscore-dangle\n    }\n    if (data) {\n      options.body._attachments = { // eslint-disable-line no-underscore-dangle\n        data: {\n          content_type: dataType,\n          data: utils.encodeBase64(data),\n        },\n      };\n    }\n    return request(token, options);\n  },\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/document/common.html#delete--db-docid\n   */\n  async removeDocument(token, documentId, rev) {\n    if (!documentId) {\n      // Prevent from deleting the whole database\n      throw new Error('Missing document ID');\n    }\n\n    return request(token, {\n      method: 'DELETE',\n      path: documentId,\n      params: { rev },\n    });\n  },\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid\n   */\n  async retrieveDocument(token, documentId, rev) {\n    return request(token, {\n      path: documentId,\n      params: { rev },\n    });\n  },\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid\n   */\n  async retrieveDocumentWithAttachments(token, documentId, rev) {\n    const body = await request(token, {\n      path: documentId,\n      params: { attachments: true, rev },\n    });\n    body.attachments = {};\n    // eslint-disable-next-line no-underscore-dangle\n    Object.entries(body._attachments).forEach(([name, attachment]) => {\n      body.attachments[name] = utils.decodeBase64(attachment.data);\n    });\n    return body;\n  },\n\n  /**\n   * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid\n   */\n  async retrieveDocumentWithRevisions(token, documentId) {\n    return request(token, {\n      path: documentId,\n      params: {\n        revs_info: true,\n      },\n    });\n  },\n};\n"
  },
  {
    "path": "src/services/providers/helpers/dropboxHelper.js",
    "content": "import networkSvc from '../../networkSvc';\nimport userSvc from '../../userSvc';\nimport store from '../../../store';\nimport badgeSvc from '../../badgeSvc';\n\nconst getAppKey = (fullAccess) => {\n  if (fullAccess) {\n    return store.getters['data/serverConf'].dropboxAppKeyFull;\n  }\n  return store.getters['data/serverConf'].dropboxAppKey;\n};\n\nconst httpHeaderSafeJson = args => args && JSON.stringify(args)\n  .replace(/[\\u007f-\\uffff]/g, c => `\\\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`);\n\nconst request = ({ accessToken }, options, args) => networkSvc.request({\n  ...options,\n  headers: {\n    ...options.headers || {},\n    'Content-Type': options.body && (typeof options.body === 'string'\n      ? 'application/octet-stream' : 'application/json; charset=utf-8'),\n    'Dropbox-API-Arg': httpHeaderSafeJson(args),\n    Authorization: `Bearer ${accessToken}`,\n  },\n});\n\n/**\n * https://www.dropbox.com/developers/documentation/http/documentation#users-get_account\n */\nconst subPrefix = 'db';\nuserSvc.setInfoResolver('dropbox', subPrefix, async (sub) => {\n  const dropboxToken = Object.values(store.getters['data/dropboxTokensBySub'])[0];\n  try {\n    const { body } = await request(dropboxToken, {\n      method: 'POST',\n      url: 'https://api.dropboxapi.com/2/users/get_account',\n      body: {\n        account_id: sub,\n      },\n    });\n\n    return {\n      id: `${subPrefix}:${body.account_id}`,\n      name: body.name.display_name,\n      imageUrl: body.profile_photo_url || '',\n    };\n  } catch (err) {\n    if (!dropboxToken || err.status !== 404) {\n      throw new Error('RETRY');\n    }\n    throw err;\n  }\n});\n\nexport default {\n  subPrefix,\n\n  /**\n   * https://www.dropbox.com/developers/documentation/http/documentation#oauth2-authorize\n   * https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account\n   */\n  async startOauth2(fullAccess, sub = null, silent = false) {\n    // Get an OAuth2 code\n    const { accessToken } = await networkSvc.startOauth2(\n      'https://www.dropbox.com/oauth2/authorize',\n      {\n        client_id: getAppKey(fullAccess),\n        response_type: 'token',\n      },\n      silent,\n    );\n\n    // Call the user info endpoint\n    const { body } = await request({ accessToken }, {\n      method: 'POST',\n      url: 'https://api.dropboxapi.com/2/users/get_current_account',\n    });\n    userSvc.addUserInfo({\n      id: `${subPrefix}:${body.account_id}`,\n      name: body.name.display_name,\n      imageUrl: body.profile_photo_url || '',\n    });\n\n    // Check the returned sub consistency\n    if (sub && `${body.account_id}` !== sub) {\n      throw new Error('Dropbox account ID not expected.');\n    }\n\n    // Build token object including scopes and sub\n    const token = {\n      accessToken,\n      name: body.name.display_name,\n      sub: `${body.account_id}`,\n      fullAccess,\n    };\n\n    // Add token to dropbox tokens\n    store.dispatch('data/addDropboxToken', token);\n    return token;\n  },\n  async addAccount(fullAccess = false) {\n    const token = await this.startOauth2(fullAccess);\n    badgeSvc.addBadge('addDropboxAccount');\n    return token;\n  },\n\n  /**\n   * https://www.dropbox.com/developers/documentation/http/documentation#files-upload\n   */\n  async uploadFile({\n    token,\n    path,\n    content,\n    fileId,\n  }) {\n    return (await request(token, {\n      method: 'POST',\n      url: 'https://content.dropboxapi.com/2/files/upload',\n      body: content,\n    }, {\n      path: fileId || path,\n      mode: 'overwrite',\n    })).body;\n  },\n\n  /**\n   * https://www.dropbox.com/developers/documentation/http/documentation#files-download\n   */\n  async downloadFile({\n    token,\n    path,\n    fileId,\n  }) {\n    const res = await request(token, {\n      method: 'POST',\n      url: 'https://content.dropboxapi.com/2/files/download',\n      raw: true,\n    }, {\n      path: fileId || path,\n    });\n    return {\n      id: JSON.parse(res.headers['dropbox-api-result']).id,\n      content: res.body,\n    };\n  },\n\n  /**\n   * https://www.dropbox.com/developers/documentation/http/documentation#list-revisions\n   */\n  async listRevisions({\n    token,\n    path,\n    fileId,\n  }) {\n    const res = await request(token, {\n      method: 'POST',\n      url: 'https://api.dropboxapi.com/2/files/list_revisions',\n      body: fileId ? {\n        path: fileId,\n        mode: 'id',\n        limit: 100,\n      } : {\n        path,\n        limit: 100,\n      },\n    });\n    return res.body.entries;\n  },\n\n  /**\n   * https://www.dropbox.com/developers/chooser\n   */\n  async openChooser(token) {\n    if (!window.Dropbox) {\n      await networkSvc.loadScript('https://www.dropbox.com/static/api/2/dropins.js');\n    }\n    return new Promise((resolve) => {\n      window.Dropbox.appKey = getAppKey(token.fullAccess);\n      window.Dropbox.choose({\n        multiselect: true,\n        linkType: 'direct',\n        success: files => resolve(files.map((file) => {\n          const path = file.link.replace(/.*\\/view\\/[^/]*/, '');\n          return decodeURI(path);\n        })),\n        cancel: () => resolve([]),\n      });\n    });\n  },\n};\n"
  },
  {
    "path": "src/services/providers/helpers/githubHelper.js",
    "content": "import utils from '../../utils';\nimport networkSvc from '../../networkSvc';\nimport store from '../../../store';\nimport userSvc from '../../userSvc';\nimport badgeSvc from '../../badgeSvc';\n\nconst getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];\n\nconst request = (token, options) => networkSvc.request({\n  ...options,\n  headers: {\n    ...options.headers || {},\n    Authorization: `token ${token.accessToken}`,\n  },\n  params: {\n    ...options.params || {},\n    t: Date.now(), // Prevent from caching\n  },\n});\n\nconst repoRequest = (token, owner, repo, options) => request(token, {\n  ...options,\n  url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${options.url}`,\n})\n  .then(res => res.body);\n\nconst getCommitMessage = (name, path) => {\n  const message = store.getters['data/computedSettings'].git[name];\n  return message.replace(/{{path}}/g, path);\n};\n\n/**\n * Getting a user from its userId is not feasible with API v3.\n * Using an undocumented endpoint...\n */\nconst subPrefix = 'gh';\nuserSvc.setInfoResolver('github', subPrefix, async (sub) => {\n  try {\n    const user = (await networkSvc.request({\n      url: `https://api.github.com/user/${sub}`,\n      params: {\n        t: Date.now(), // Prevent from caching\n      },\n    })).body;\n\n    return {\n      id: `${subPrefix}:${user.id}`,\n      name: user.login,\n      imageUrl: user.avatar_url || '',\n    };\n  } catch (err) {\n    if (err.status !== 404) {\n      throw new Error('RETRY');\n    }\n    throw err;\n  }\n});\n\nexport default {\n  subPrefix,\n\n  /**\n   * https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/\n   */\n  async startOauth2(scopes, sub = null, silent = false) {\n    const clientId = store.getters['data/serverConf'].githubClientId;\n\n    // Get an OAuth2 code\n    const { code } = await networkSvc.startOauth2(\n      'https://github.com/login/oauth/authorize',\n      {\n        client_id: clientId,\n        scope: scopes.join(' '),\n      },\n      silent,\n    );\n\n    // Exchange code with token\n    const accessToken = (await networkSvc.request({\n      method: 'GET',\n      url: 'oauth2/githubToken',\n      params: {\n        clientId,\n        code,\n      },\n    })).body;\n\n    // Call the user info endpoint\n    const user = (await networkSvc.request({\n      method: 'GET',\n      url: 'https://api.github.com/user',\n      headers: {\n        Authorization: `token ${accessToken}`,\n      },\n    })).body;\n    userSvc.addUserInfo({\n      id: `${subPrefix}:${user.id}`,\n      name: user.login,\n      imageUrl: user.avatar_url || '',\n    });\n\n    // Check the returned sub consistency\n    if (sub && `${user.id}` !== sub) {\n      throw new Error('GitHub account ID not expected.');\n    }\n\n    // Build token object including scopes and sub\n    const token = {\n      scopes,\n      accessToken,\n      name: user.login,\n      sub: `${user.id}`,\n      repoFullAccess: scopes.includes('repo'),\n    };\n\n    // Add token to github tokens\n    store.dispatch('data/addGithubToken', token);\n    return token;\n  },\n  async addAccount(repoFullAccess = false) {\n    const token = await this.startOauth2(getScopes({ repoFullAccess }));\n    badgeSvc.addBadge('addGitHubAccount');\n    return token;\n  },\n\n  /**\n   * https://developer.github.com/v3/repos/commits/#get-a-single-commit\n   * https://developer.github.com/v3/git/trees/#get-a-tree\n   */\n  async getTree({\n    token,\n    owner,\n    repo,\n    branch,\n  }) {\n    const { commit } = await repoRequest(token, owner, repo, {\n      url: `commits/${encodeURIComponent(branch)}`,\n    });\n    const { tree, truncated } = await repoRequest(token, owner, repo, {\n      url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`,\n    });\n    if (truncated) {\n      throw new Error('Git tree too big. Please remove some files in the repository.');\n    }\n    return tree;\n  },\n\n  /**\n   * https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository\n   */\n  async getCommits({\n    token,\n    owner,\n    repo,\n    sha,\n    path,\n  }) {\n    return repoRequest(token, owner, repo, {\n      url: 'commits',\n      params: { sha, path },\n    });\n  },\n\n  /**\n   * https://developer.github.com/v3/repos/contents/#create-a-file\n   * https://developer.github.com/v3/repos/contents/#update-a-file\n   */\n  async uploadFile({\n    token,\n    owner,\n    repo,\n    branch,\n    path,\n    content,\n    sha,\n  }) {\n    return repoRequest(token, owner, repo, {\n      method: 'PUT',\n      url: `contents/${encodeURIComponent(path)}`,\n      body: {\n        message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path),\n        content: utils.encodeBase64(content),\n        sha,\n        branch,\n      },\n    });\n  },\n\n  /**\n   * https://developer.github.com/v3/repos/contents/#delete-a-file\n   */\n  async removeFile({\n    token,\n    owner,\n    repo,\n    branch,\n    path,\n    sha,\n  }) {\n    return repoRequest(token, owner, repo, {\n      method: 'DELETE',\n      url: `contents/${encodeURIComponent(path)}`,\n      body: {\n        message: getCommitMessage('deleteFileMessage', path),\n        sha,\n        branch,\n      },\n    });\n  },\n\n  /**\n   * https://developer.github.com/v3/repos/contents/#get-contents\n   */\n  async downloadFile({\n    token,\n    owner,\n    repo,\n    branch,\n    path,\n  }) {\n    const { sha, content } = await repoRequest(token, owner, repo, {\n      url: `contents/${encodeURIComponent(path)}`,\n      params: { ref: branch },\n    });\n    return {\n      sha,\n      data: utils.decodeBase64(content),\n    };\n  },\n\n  /**\n   * https://developer.github.com/v3/gists/#create-a-gist\n   * https://developer.github.com/v3/gists/#edit-a-gist\n   */\n  async uploadGist({\n    token,\n    description,\n    filename,\n    content,\n    isPublic,\n    gistId,\n  }) {\n    const { body } = await request(token, gistId ? {\n      method: 'PATCH',\n      url: `https://api.github.com/gists/${gistId}`,\n      body: {\n        description,\n        files: {\n          [filename]: {\n            content,\n          },\n        },\n      },\n    } : {\n      method: 'POST',\n      url: 'https://api.github.com/gists',\n      body: {\n        description,\n        files: {\n          [filename]: {\n            content,\n          },\n        },\n        public: isPublic,\n      },\n    });\n    return body;\n  },\n\n  /**\n   * https://developer.github.com/v3/gists/#get-a-single-gist\n   */\n  async downloadGist({\n    token,\n    gistId,\n    filename,\n  }) {\n    const result = (await request(token, {\n      url: `https://api.github.com/gists/${gistId}`,\n    })).body.files[filename];\n    if (!result) {\n      throw new Error('Gist file not found.');\n    }\n    return result.content;\n  },\n\n  /**\n   * https://developer.github.com/v3/gists/#list-gist-commits\n   */\n  async getGistCommits({\n    token,\n    gistId,\n  }) {\n    const { body } = await request(token, {\n      url: `https://api.github.com/gists/${gistId}/commits`,\n    });\n    return body;\n  },\n\n  /**\n   * https://developer.github.com/v3/gists/#get-a-specific-revision-of-a-gist\n   */\n  async downloadGistRevision({\n    token,\n    gistId,\n    filename,\n    sha,\n  }) {\n    const result = (await request(token, {\n      url: `https://api.github.com/gists/${gistId}/${sha}`,\n    })).body.files[filename];\n    if (!result) {\n      throw new Error('Gist file not found.');\n    }\n    return result.content;\n  },\n};\n"
  },
  {
    "path": "src/services/providers/helpers/gitlabHelper.js",
    "content": "import utils from '../../utils';\nimport networkSvc from '../../networkSvc';\nimport store from '../../../store';\nimport userSvc from '../../userSvc';\nimport badgeSvc from '../../badgeSvc';\n\nconst request = ({ accessToken, serverUrl }, options) => networkSvc.request({\n  ...options,\n  url: `${serverUrl}/api/v4/${options.url}`,\n  headers: {\n    ...options.headers || {},\n    Authorization: `Bearer ${accessToken}`,\n  },\n})\n  .then(res => res.body);\n\nconst getCommitMessage = (name, path) => {\n  const message = store.getters['data/computedSettings'].git[name];\n  return message.replace(/{{path}}/g, path);\n};\n\n/**\n * https://docs.gitlab.com/ee/api/users.html#for-user\n */\nconst subPrefix = 'gl';\nuserSvc.setInfoResolver('gitlab', subPrefix, async (sub) => {\n  try {\n    const [, serverUrl, id] = sub.match(/^(.+)\\/([^/]+)$/);\n    const user = (await networkSvc.request({\n      url: `${serverUrl}/api/v4/users/${id}`,\n    })).body;\n    const uniqueSub = `${serverUrl}/${user.id}`;\n\n    return {\n      id: `${subPrefix}:${uniqueSub}`,\n      name: user.username,\n      imageUrl: user.avatar_url || '',\n    };\n  } catch (err) {\n    if (err.status !== 404) {\n      throw new Error('RETRY');\n    }\n    throw err;\n  }\n});\n\nexport default {\n  subPrefix,\n\n  /**\n   * https://docs.gitlab.com/ee/api/oauth2.html\n   */\n  async startOauth2(serverUrl, applicationId, sub = null, silent = false) {\n    // Get an OAuth2 code\n    const { accessToken } = await networkSvc.startOauth2(\n      `${serverUrl}/oauth/authorize`,\n      {\n        client_id: applicationId,\n        response_type: 'token',\n        scope: 'api',\n      },\n      silent,\n    );\n\n    // Call the user info endpoint\n    const user = await request({ accessToken, serverUrl }, {\n      url: 'user',\n    });\n    const uniqueSub = `${serverUrl}/${user.id}`;\n    userSvc.addUserInfo({\n      id: `${subPrefix}:${uniqueSub}`,\n      name: user.username,\n      imageUrl: user.avatar_url || '',\n    });\n\n    // Check the returned sub consistency\n    if (sub && uniqueSub !== sub) {\n      throw new Error('GitLab account ID not expected.');\n    }\n\n    // Build token object including scopes and sub\n    const token = {\n      accessToken,\n      name: user.username,\n      serverUrl,\n      sub: uniqueSub,\n    };\n\n    // Add token to gitlab tokens\n    store.dispatch('data/addGitlabToken', token);\n    return token;\n  },\n  async addAccount(serverUrl, applicationId, sub = null) {\n    const token = await this.startOauth2(serverUrl, applicationId, sub);\n    badgeSvc.addBadge('addGitLabAccount');\n    return token;\n  },\n\n  /**\n   * https://docs.gitlab.com/ee/api/projects.html#get-single-project\n   */\n  async getProjectId(token, { projectPath, projectId }) {\n    if (projectId) {\n      return projectId;\n    }\n\n    const project = await request(token, {\n      url: `projects/${encodeURIComponent(projectPath)}`,\n    });\n    return project.id;\n  },\n\n  /**\n   * https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree\n   */\n  async getTree({\n    token,\n    projectId,\n    branch,\n  }) {\n    return request(token, {\n      url: `projects/${encodeURIComponent(projectId)}/repository/tree`,\n      params: {\n        ref: branch,\n        recursive: true,\n        per_page: 9999,\n      },\n    });\n  },\n\n  /**\n   * https://docs.gitlab.com/ee/api/commits.html#list-repository-commits\n   */\n  async getCommits({\n    token,\n    projectId,\n    branch,\n    path,\n  }) {\n    return request(token, {\n      url: `projects/${encodeURIComponent(projectId)}/repository/commits`,\n      params: {\n        ref_name: branch,\n        path,\n      },\n    });\n  },\n\n  /**\n   * https://docs.gitlab.com/ee/api/repository_files.html#create-new-file-in-repository\n   * https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository\n   */\n  async uploadFile({\n    token,\n    projectId,\n    branch,\n    path,\n    content,\n    sha,\n  }) {\n    return request(token, {\n      method: sha ? 'PUT' : 'POST',\n      url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`,\n      body: {\n        commit_message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path),\n        content,\n        last_commit_id: sha,\n        branch,\n      },\n    });\n  },\n\n  /**\n   * https://docs.gitlab.com/ee/api/repository_files.html#delete-existing-file-in-repository\n   */\n  async removeFile({\n    token,\n    projectId,\n    branch,\n    path,\n    sha,\n  }) {\n    return request(token, {\n      method: 'DELETE',\n      url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`,\n      body: {\n        commit_message: getCommitMessage('deleteFileMessage', path),\n        last_commit_id: sha,\n        branch,\n      },\n    });\n  },\n\n  /**\n   * https://docs.gitlab.com/ee/api/repository_files.html#get-file-from-repository\n   */\n  async downloadFile({\n    token,\n    projectId,\n    branch,\n    path,\n  }) {\n    const res = await request(token, {\n      url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`,\n      params: { ref: branch },\n    });\n    return {\n      sha: res.last_commit_id,\n      data: utils.decodeBase64(res.content),\n    };\n  },\n};\n"
  },
  {
    "path": "src/services/providers/helpers/googleHelper.js",
    "content": "import utils from '../../utils';\nimport networkSvc from '../../networkSvc';\nimport store from '../../../store';\nimport userSvc from '../../userSvc';\nimport badgeSvc from '../../badgeSvc';\n\nconst appsDomain = null;\nconst tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h)\n\nconst driveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata'];\nconst getDriveScopes = token => [token.driveFullAccess\n  ? 'https://www.googleapis.com/auth/drive'\n  : 'https://www.googleapis.com/auth/drive.file',\n'https://www.googleapis.com/auth/drive.install'];\nconst bloggerScopes = ['https://www.googleapis.com/auth/blogger'];\nconst photosScopes = ['https://www.googleapis.com/auth/photos'];\n\nconst checkIdToken = (idToken) => {\n  try {\n    const token = idToken.split('.');\n    const payload = JSON.parse(utils.decodeBase64(token[1]));\n    const clientId = store.getters['data/serverConf'].googleClientId;\n    return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000;\n  } catch (e) {\n    return false;\n  }\n};\n\nlet driveState;\nif (utils.queryParams.providerId === 'googleDrive') {\n  try {\n    driveState = JSON.parse(utils.queryParams.state);\n  } catch (e) {\n    // Ignore\n  }\n}\n\n/**\n * https://developers.google.com/people/api/rest/v1/people/get\n */\nconst getUser = async (sub, token) => {\n  const apiKey = store.getters['data/serverConf'].googleApiKey;\n  const url = `https://people.googleapis.com/v1/people/${sub}?personFields=names,photos&key=${apiKey}`;\n  const { body } = await networkSvc.request(sub === 'me' && token\n    ? {\n      method: 'GET',\n      url,\n      headers: {\n        Authorization: `Bearer ${token.accessToken}`,\n      },\n    }\n    : {\n      method: 'GET',\n      url,\n    }, true);\n  return body;\n};\n\nconst subPrefix = 'go';\nuserSvc.setInfoResolver('google', subPrefix, async (sub) => {\n  try {\n    const googleToken = Object.values(store.getters['data/googleTokensBySub'])[0];\n    const body = await getUser(sub, googleToken);\n    const name = (body.names && body.names[0]) || {};\n    const photo = (body.photos && body.photos[0]) || {};\n    return {\n      id: `${subPrefix}:${sub}`,\n      name: name.displayName,\n      imageUrl: (photo.url || '').replace(/\\bsz?=\\d+$/, 'sz=40'),\n    };\n  } catch (err) {\n    if (err.status !== 404) {\n      throw new Error('RETRY');\n    }\n    throw err;\n  }\n});\n\nexport default {\n  subPrefix,\n  folderMimeType: 'application/vnd.google-apps.folder',\n  driveState,\n  driveActionFolder: null,\n  driveActionFiles: [],\n  async $request(token, options) {\n    try {\n      return (await networkSvc.request({\n        ...options,\n        headers: {\n          ...options.headers || {},\n          Authorization: `Bearer ${token.accessToken}`,\n        },\n      }, true)).body;\n    } catch (err) {\n      const { reason } = (((err.body || {}).error || {}).errors || [])[0] || {};\n      if (reason === 'authError') {\n        // Mark the token as revoked and get a new one\n        store.dispatch('data/addGoogleToken', {\n          ...token,\n          expiresOn: 0,\n        });\n        // Refresh token and retry\n        const refreshedToken = await this.refreshToken(token, token.scopes);\n        return this.$request(refreshedToken, options);\n      }\n      throw err;\n    }\n  },\n\n  /**\n   * https://developers.google.com/identity/protocols/OpenIDConnect\n   */\n  async startOauth2(scopes, sub = null, silent = false) {\n    const clientId = store.getters['data/serverConf'].googleClientId;\n\n    // Get an OAuth2 code\n    const { accessToken, expiresIn, idToken } = await networkSvc.startOauth2(\n      'https://accounts.google.com/o/oauth2/v2/auth',\n      {\n        client_id: clientId,\n        response_type: 'token id_token',\n        scope: ['openid', 'profile', ...scopes].join(' '),\n        hd: appsDomain,\n        login_hint: sub,\n        prompt: silent ? 'none' : null,\n        nonce: utils.uid(),\n      },\n      silent,\n    );\n\n    // Call the token info endpoint\n    const { body } = await networkSvc.request({\n      method: 'POST',\n      url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',\n      params: {\n        access_token: accessToken,\n      },\n    }, true);\n\n    // Check the returned client ID consistency\n    if (body.aud !== clientId) {\n      throw new Error('Client ID inconsistent.');\n    }\n    // Check the returned sub consistency\n    if (sub && `${body.sub}` !== sub) {\n      throw new Error('Google account ID not expected.');\n    }\n\n    // Build token object including scopes and sub\n    const existingToken = store.getters['data/googleTokensBySub'][body.sub];\n    const token = {\n      scopes,\n      accessToken,\n      expiresOn: Date.now() + (expiresIn * 1000),\n      idToken,\n      sub: body.sub,\n      name: (existingToken || {}).name || 'Someone',\n      isLogin: !store.getters['workspace/mainWorkspaceToken'] &&\n        scopes.includes('https://www.googleapis.com/auth/drive.appdata'),\n      isSponsor: false,\n      isDrive: scopes.includes('https://www.googleapis.com/auth/drive') ||\n        scopes.includes('https://www.googleapis.com/auth/drive.file'),\n      isBlogger: scopes.includes('https://www.googleapis.com/auth/blogger'),\n      isPhotos: scopes.includes('https://www.googleapis.com/auth/photos'),\n      driveFullAccess: scopes.includes('https://www.googleapis.com/auth/drive'),\n    };\n\n    // Call the user info endpoint\n    const user = await getUser('me', token);\n    const userId = user.resourceName.split('/')[1];\n    const name = user.names[0] || {};\n    const photo = user.photos[0] || {};\n    if (name.displayName) {\n      token.name = name.displayName;\n    }\n    userSvc.addUserInfo({\n      id: `${subPrefix}:${userId}`,\n      name: name.displayName,\n      imageUrl: (photo.url || '').replace(/\\bsz?=\\d+$/, 'sz=40'),\n    });\n\n    if (existingToken) {\n      // We probably retrieved a new token with restricted scopes.\n      // That's no problem, token will be refreshed later with merged scopes.\n      // Restore flags\n      Object.assign(token, {\n        isLogin: existingToken.isLogin || token.isLogin,\n        isSponsor: existingToken.isSponsor,\n        isDrive: existingToken.isDrive || token.isDrive,\n        isBlogger: existingToken.isBlogger || token.isBlogger,\n        isPhotos: existingToken.isPhotos || token.isPhotos,\n        driveFullAccess: existingToken.driveFullAccess || token.driveFullAccess,\n      });\n    }\n\n    if (token.isLogin) {\n      try {\n        const res = await networkSvc.request({\n          method: 'GET',\n          url: 'userInfo',\n          params: {\n            idToken: token.idToken,\n          },\n        });\n        token.isSponsor = res.body.sponsorUntil > Date.now();\n        if (token.isSponsor) {\n          badgeSvc.addBadge('sponsor');\n        }\n      } catch (err) {\n        // Ignore\n      }\n    }\n\n    // Add token to google tokens\n    await store.dispatch('data/addGoogleToken', token);\n    return token;\n  },\n  async refreshToken(token, scopes = []) {\n    const { sub } = token;\n    const lastToken = store.getters['data/googleTokensBySub'][sub];\n    const mergedScopes = [...new Set([\n      ...scopes,\n      ...lastToken.scopes,\n    ])];\n\n    if (\n      // If we already have permissions for the requested scopes\n      mergedScopes.length === lastToken.scopes.length &&\n      // And lastToken is not expired\n      lastToken.expiresOn > Date.now() + tokenExpirationMargin &&\n      // And in case of a login token, ID token is still valid\n      (!lastToken.isLogin || checkIdToken(lastToken.idToken))\n    ) {\n      return lastToken;\n    }\n\n    // New scopes are requested or existing token is about to expire.\n    // Try to get a new token in background\n    try {\n      return await this.startOauth2(mergedScopes, sub, true);\n    } catch (err) {\n      // If it fails try to popup a window\n      if (store.state.offline) {\n        throw err;\n      }\n      await store.dispatch('modal/open', {\n        type: 'providerRedirection',\n        name: 'Google',\n      });\n      return this.startOauth2(mergedScopes, sub);\n    }\n  },\n  signin() {\n    return this.startOauth2(driveAppDataScopes);\n  },\n  async addDriveAccount(fullAccess = false, sub = null) {\n    const token = await this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }), sub);\n    badgeSvc.addBadge('addGoogleDriveAccount');\n    return token;\n  },\n  async addBloggerAccount() {\n    const token = await this.startOauth2(bloggerScopes);\n    badgeSvc.addBadge('addBloggerAccount');\n    return token;\n  },\n  async addPhotosAccount() {\n    const token = await this.startOauth2(photosScopes);\n    badgeSvc.addBadge('addGooglePhotosAccount');\n    return token;\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/reference/files/create\n   * https://developers.google.com/drive/v3/reference/files/update\n   * https://developers.google.com/drive/v3/web/simple-upload\n   */\n  async $uploadFile({\n    refreshedToken,\n    name,\n    parents,\n    appProperties,\n    media = null,\n    mediaType = null,\n    fileId = null,\n    oldParents = null,\n    ifNotTooLate = cb => cb(),\n  }) {\n    // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late\n    return ifNotTooLate(() => {\n      const options = {\n        method: 'POST',\n        url: 'https://www.googleapis.com/drive/v3/files',\n      };\n      const params = {\n        supportsTeamDrives: true,\n      };\n      const metadata = { name, appProperties };\n      if (fileId) {\n        options.method = 'PATCH';\n        options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`;\n        if (parents && oldParents) {\n          params.addParents = parents\n            .filter(parent => !oldParents.includes(parent))\n            .join(',');\n          params.removeParents = oldParents\n            .filter(parent => !parents.includes(parent))\n            .join(',');\n        }\n      } else if (parents) {\n        metadata.parents = parents;\n      }\n      if (media) {\n        const boundary = `-------${utils.uid()}`;\n        const delimiter = `\\r\\n--${boundary}\\r\\n`;\n        const closeDelimiter = `\\r\\n--${boundary}--`;\n        let multipartRequestBody = '';\n        multipartRequestBody += delimiter;\n        multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\\r\\n\\r\\n';\n        multipartRequestBody += JSON.stringify(metadata);\n        multipartRequestBody += delimiter;\n        multipartRequestBody += `Content-Type: ${mediaType || 'text/plain'}; charset=UTF-8\\r\\n\\r\\n`;\n        multipartRequestBody += media;\n        multipartRequestBody += closeDelimiter;\n        options.url = options.url.replace(\n          'https://www.googleapis.com/',\n          'https://www.googleapis.com/upload/',\n        );\n        return this.$request(refreshedToken, {\n          ...options,\n          params: {\n            ...params,\n            uploadType: 'multipart',\n          },\n          headers: {\n            'Content-Type': `multipart/mixed; boundary=\"${boundary}\"`,\n          },\n          body: multipartRequestBody,\n        });\n      }\n      if (mediaType) {\n        metadata.mimeType = mediaType;\n      }\n      return this.$request(refreshedToken, {\n        ...options,\n        body: metadata,\n        params,\n      });\n    });\n  },\n  async uploadFile({\n    token,\n    name,\n    parents,\n    appProperties,\n    media,\n    mediaType,\n    fileId,\n    oldParents,\n    ifNotTooLate,\n  }) {\n    const refreshedToken = await this.refreshToken(token, getDriveScopes(token));\n    return this.$uploadFile({\n      refreshedToken,\n      name,\n      parents,\n      appProperties,\n      media,\n      mediaType,\n      fileId,\n      oldParents,\n      ifNotTooLate,\n    });\n  },\n  async uploadAppDataFile({\n    token,\n    name,\n    media,\n    fileId,\n    ifNotTooLate,\n  }) {\n    const refreshedToken = await this.refreshToken(token, driveAppDataScopes);\n    return this.$uploadFile({\n      refreshedToken,\n      name,\n      parents: ['appDataFolder'],\n      media,\n      fileId,\n      ifNotTooLate,\n    });\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/reference/files/get\n   */\n  async getFile(token, id) {\n    const refreshedToken = await this.refreshToken(token, getDriveScopes(token));\n    return this.$request(refreshedToken, {\n      method: 'GET',\n      url: `https://www.googleapis.com/drive/v3/files/${id}`,\n      params: {\n        fields: 'id,name,mimeType,appProperties,teamDriveId',\n        supportsTeamDrives: true,\n      },\n    });\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/web/manage-downloads\n   */\n  async $downloadFile(refreshedToken, id) {\n    return this.$request(refreshedToken, {\n      method: 'GET',\n      url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,\n      raw: true,\n    });\n  },\n  async downloadFile(token, id) {\n    const refreshedToken = await this.refreshToken(token, getDriveScopes(token));\n    return this.$downloadFile(refreshedToken, id);\n  },\n  async downloadAppDataFile(token, id) {\n    const refreshedToken = await this.refreshToken(token, driveAppDataScopes);\n    return this.$downloadFile(refreshedToken, id);\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/reference/files/delete\n   */\n  async $removeFile(refreshedToken, id, ifNotTooLate = cb => cb()) {\n    // Refreshing a token can take a while if an oauth window pops up, so check if it's too late\n    return ifNotTooLate(() => this.$request(refreshedToken, {\n      method: 'DELETE',\n      url: `https://www.googleapis.com/drive/v3/files/${id}`,\n      params: {\n        supportsTeamDrives: true,\n      },\n    }));\n  },\n  async removeFile(token, id, ifNotTooLate) {\n    const refreshedToken = await this.refreshToken(token, getDriveScopes(token));\n    return this.$removeFile(refreshedToken, id, ifNotTooLate);\n  },\n  async removeAppDataFile(token, id, ifNotTooLate = cb => cb()) {\n    const refreshedToken = await this.refreshToken(token, driveAppDataScopes);\n    return this.$removeFile(refreshedToken, id, ifNotTooLate);\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/reference/revisions/list\n   */\n  async $getFileRevisions(refreshedToken, id) {\n    const allRevisions = [];\n    const getPage = async (pageToken) => {\n      const { revisions, nextPageToken } = await this.$request(refreshedToken, {\n        method: 'GET',\n        url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`,\n        params: {\n          pageToken,\n          pageSize: 1000,\n          fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)',\n        },\n      });\n      revisions.forEach((revision) => {\n        userSvc.addUserInfo({\n          id: `${subPrefix}:${revision.lastModifyingUser.permissionId}`,\n          name: revision.lastModifyingUser.displayName,\n          imageUrl: revision.lastModifyingUser.photoLink || '',\n        });\n        allRevisions.push(revision);\n      });\n      if (nextPageToken) {\n        return getPage(nextPageToken);\n      }\n      return allRevisions;\n    };\n    return getPage();\n  },\n  async getFileRevisions(token, id) {\n    const refreshedToken = await this.refreshToken(token, getDriveScopes(token));\n    return this.$getFileRevisions(refreshedToken, id);\n  },\n  async getAppDataFileRevisions(token, id) {\n    const refreshedToken = await this.refreshToken(token, driveAppDataScopes);\n    return this.$getFileRevisions(refreshedToken, id);\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/reference/revisions/get\n   */\n  async $downloadFileRevision(refreshedToken, id, revisionId) {\n    return this.$request(refreshedToken, {\n      method: 'GET',\n      url: `https://www.googleapis.com/drive/v3/files/${id}/revisions/${revisionId}?alt=media`,\n      raw: true,\n    });\n  },\n  async downloadFileRevision(token, fileId, revisionId) {\n    const refreshedToken = await this.refreshToken(token, getDriveScopes(token));\n    return this.$downloadFileRevision(refreshedToken, fileId, revisionId);\n  },\n  async downloadAppDataFileRevision(token, fileId, revisionId) {\n    const refreshedToken = await this.refreshToken(token, driveAppDataScopes);\n    return this.$downloadFileRevision(refreshedToken, fileId, revisionId);\n  },\n\n  /**\n   * https://developers.google.com/drive/v3/reference/changes/list\n   */\n  async getChanges(token, startPageToken, isAppData, teamDriveId = null) {\n    const result = {\n      changes: [],\n    };\n    let fileFields = 'file/name';\n    if (!isAppData) {\n      fileFields += ',file/parents,file/mimeType,file/appProperties';\n    }\n    const refreshedToken = await this.refreshToken(\n      token,\n      isAppData ? driveAppDataScopes : getDriveScopes(token),\n    );\n\n    const getPage = async (pageToken = '1') => {\n      const { changes, nextPageToken, newStartPageToken } = await this.$request(refreshedToken, {\n        method: 'GET',\n        url: 'https://www.googleapis.com/drive/v3/changes',\n        params: {\n          pageToken,\n          spaces: isAppData ? 'appDataFolder' : 'drive',\n          pageSize: 1000,\n          fields: `nextPageToken,newStartPageToken,changes(fileId,${fileFields})`,\n          supportsTeamDrives: true,\n          includeTeamDriveItems: !!teamDriveId,\n          teamDriveId,\n        },\n      });\n      result.changes = [...result.changes, ...changes.filter(item => item.fileId)];\n      if (nextPageToken) {\n        return getPage(nextPageToken);\n      }\n      result.startPageToken = newStartPageToken;\n      return result;\n    };\n    return getPage(startPageToken);\n  },\n\n  /**\n   * https://developers.google.com/blogger/docs/3.0/reference/blogs/getByUrl\n   * https://developers.google.com/blogger/docs/3.0/reference/posts/insert\n   * https://developers.google.com/blogger/docs/3.0/reference/posts/update\n   */\n  async uploadBlogger({\n    token,\n    blogUrl,\n    blogId,\n    postId,\n    title,\n    content,\n    labels,\n    isDraft,\n    published,\n    isPage,\n  }) {\n    const refreshedToken = await this.refreshToken(token, bloggerScopes);\n\n    // Get the blog ID\n    const blog = { id: blogId };\n    if (!blog.id) {\n      blog.id = (await this.$request(refreshedToken, {\n        url: 'https://www.googleapis.com/blogger/v3/blogs/byurl',\n        params: {\n          url: blogUrl,\n        },\n      })).id;\n    }\n\n    // Create/update the post/page\n    const path = isPage ? 'pages' : 'posts';\n    let options = {\n      method: 'POST',\n      url: `https://www.googleapis.com/blogger/v3/blogs/${blog.id}/${path}/`,\n      body: {\n        kind: isPage ? 'blogger#page' : 'blogger#post',\n        blog,\n        title,\n        content,\n      },\n    };\n    if (labels) {\n      options.body.labels = labels;\n    }\n    if (published) {\n      options.body.published = published.toISOString();\n    }\n    // If it's an update\n    if (postId) {\n      options.method = 'PUT';\n      options.url += postId;\n      options.body.id = postId;\n    }\n    const post = await this.$request(refreshedToken, options);\n    if (isPage) {\n      return post;\n    }\n\n    // Revert/publish post\n    options = {\n      method: 'POST',\n      url: `https://www.googleapis.com/blogger/v3/blogs/${post.blog.id}/posts/${post.id}/`,\n      params: {},\n    };\n    if (isDraft) {\n      options.url += 'revert';\n    } else {\n      options.url += 'publish';\n      if (published) {\n        options.params.publishDate = published.toISOString();\n      }\n    }\n    return this.$request(refreshedToken, options);\n  },\n\n  /**\n   * https://developers.google.com/picker/docs/\n   */\n  async openPicker(token, type = 'doc') {\n    const scopes = type === 'img' ? photosScopes : getDriveScopes(token);\n    if (!window.google) {\n      await networkSvc.loadScript('https://apis.google.com/js/api.js');\n      await new Promise((resolve, reject) => window.gapi.load('picker', {\n        callback: resolve,\n        onerror: reject,\n        timeout: 30000,\n        ontimeout: reject,\n      }));\n    }\n    const refreshedToken = await this.refreshToken(token, scopes);\n    const { google } = window;\n    return new Promise((resolve) => {\n      let picker;\n      const pickerBuilder = new google.picker.PickerBuilder()\n        .setOAuthToken(refreshedToken.accessToken)\n        .enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES)\n        .hideTitleBar()\n        .setCallback((data) => {\n          switch (data[google.picker.Response.ACTION]) {\n            case google.picker.Action.PICKED:\n            case google.picker.Action.CANCEL:\n              resolve(data.docs || []);\n              picker.dispose();\n              break;\n            default:\n          }\n        });\n      switch (type) {\n        default:\n        case 'doc': {\n          const mimeTypes = [\n            'text/plain',\n            'text/x-markdown',\n            'application/octet-stream',\n          ].join(',');\n\n          const view = new google.picker.DocsView(google.picker.ViewId.DOCS);\n          view.setMimeTypes(mimeTypes);\n          pickerBuilder.addView(view);\n\n          const teamDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS);\n          teamDriveView.setMimeTypes(mimeTypes);\n          teamDriveView.setEnableTeamDrives(true);\n          pickerBuilder.addView(teamDriveView);\n\n          pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED);\n          pickerBuilder.enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES);\n          break;\n        }\n        case 'folder': {\n          const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS);\n          folderView.setSelectFolderEnabled(true);\n          folderView.setMimeTypes(this.folderMimeType);\n          pickerBuilder.addView(folderView);\n\n          const teamDriveView = new google.picker.DocsView(google.picker.ViewId.FOLDERS);\n          teamDriveView.setSelectFolderEnabled(true);\n          teamDriveView.setEnableTeamDrives(true);\n          teamDriveView.setMimeTypes(this.folderMimeType);\n          pickerBuilder.addView(teamDriveView);\n          break;\n        }\n        case 'img': {\n          const view = new google.picker.PhotosView();\n          view.setType('highlights');\n          pickerBuilder.addView(view);\n          pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD);\n          break;\n        }\n      }\n      picker = pickerBuilder.build();\n      picker.setVisible(true);\n    });\n  },\n};\n"
  },
  {
    "path": "src/services/providers/helpers/wordpressHelper.js",
    "content": "import networkSvc from '../../networkSvc';\nimport store from '../../../store';\nimport badgeSvc from '../../badgeSvc';\n\nconst tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks)\n\nconst request = (token, options) => networkSvc.request({\n  ...options,\n  headers: {\n    ...options.headers || {},\n    Authorization: `Bearer ${token.accessToken}`,\n  },\n})\n  .then(res => res.body);\n\nexport default {\n  /**\n   * https://developer.wordpress.com/docs/oauth2/\n   */\n  async startOauth2(sub = null, silent = false) {\n    const clientId = store.getters['data/serverConf'].wordpressClientId;\n\n    // Get an OAuth2 code\n    const { accessToken, expiresIn } = await networkSvc.startOauth2(\n      'https://public-api.wordpress.com/oauth2/authorize',\n      {\n        client_id: clientId,\n        response_type: 'token',\n        scope: 'global',\n      },\n      silent,\n    );\n\n    // Call the user info endpoint\n    const body = await request({ accessToken }, {\n      url: 'https://public-api.wordpress.com/rest/v1.1/me',\n    });\n\n    // Check the returned sub consistency\n    if (sub && `${body.ID}` !== sub) {\n      throw new Error('WordPress account ID not expected.');\n    }\n    // Build token object including scopes and sub\n    const token = {\n      accessToken,\n      expiresOn: Date.now() + (expiresIn * 1000),\n      name: body.display_name,\n      sub: `${body.ID}`,\n    };\n    // Add token to wordpress tokens\n    store.dispatch('data/addWordpressToken', token);\n    return token;\n  },\n  async refreshToken(token) {\n    const { sub } = token;\n    const lastToken = store.getters['data/wordpressTokensBySub'][sub];\n\n    if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) {\n      return lastToken;\n    }\n    // Existing token is going to expire.\n    // Try to get a new token in background\n    await store.dispatch('modal/open', {\n      type: 'providerRedirection',\n      name: 'WordPress',\n    });\n    return this.startOauth2(sub);\n  },\n  async addAccount(fullAccess = false) {\n    const token = await this.startOauth2(fullAccess);\n    badgeSvc.addBadge('addWordpressAccount');\n    return token;\n  },\n\n  /**\n   * https://developer.wordpress.com/docs/api/1.2/post/sites/%24site/posts/new/\n   * https://developer.wordpress.com/docs/api/1.2/post/sites/%24site/posts/%24post_ID/\n   */\n  async uploadPost({\n    token,\n    domain,\n    siteId,\n    postId,\n    title,\n    content,\n    tags,\n    categories,\n    excerpt,\n    author,\n    featuredImage,\n    status,\n    date,\n  }) {\n    const refreshedToken = await this.refreshToken(token);\n    return request(refreshedToken, {\n      method: 'POST',\n      url: `https://public-api.wordpress.com/rest/v1.2/sites/${siteId || domain}/posts/${postId || 'new'}`,\n      body: {\n        content,\n        title,\n        tags,\n        categories,\n        excerpt,\n        author,\n        featured_image: featuredImage || '',\n        status,\n        date: date && date.toISOString(),\n      },\n    });\n  },\n};\n"
  },
  {
    "path": "src/services/providers/helpers/zendeskHelper.js",
    "content": "import networkSvc from '../../networkSvc';\nimport store from '../../../store';\nimport badgeSvc from '../../badgeSvc';\n\nconst request = (token, options) => networkSvc.request({\n  ...options,\n  headers: {\n    ...options.headers || {},\n    Authorization: `Bearer ${token.accessToken}`,\n  },\n})\n  .then(res => res.body);\n\n\nexport default {\n  /**\n   * https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application\n   */\n  async startOauth2(subdomain, clientId, sub = null, silent = false) {\n    // Get an OAuth2 code\n    const { accessToken } = await networkSvc.startOauth2(\n      `https://${subdomain}.zendesk.com/oauth/authorizations/new`,\n      {\n        client_id: clientId,\n        response_type: 'token',\n        scope: 'read hc:write',\n      },\n      silent,\n    );\n\n    // Call the user info endpoint\n    const { user } = await request({ accessToken }, {\n      url: `https://${subdomain}.zendesk.com/api/v2/users/me.json`,\n    });\n    const uniqueSub = `${subdomain}/${user.id}`;\n\n    // Check the returned sub consistency\n    if (sub && uniqueSub !== sub) {\n      throw new Error('Zendesk account ID not expected.');\n    }\n\n    // Build token object including scopes and sub\n    const token = {\n      accessToken,\n      name: user.name,\n      subdomain,\n      sub: uniqueSub,\n    };\n\n    // Add token to zendesk tokens\n    store.dispatch('data/addZendeskToken', token);\n    return token;\n  },\n  async addAccount(subdomain, clientId) {\n    const token = await this.startOauth2(subdomain, clientId);\n    badgeSvc.addBadge('addZendeskAccount');\n    return token;\n  },\n\n  /**\n   * https://developer.zendesk.com/rest_api/docs/help_center/articles\n   */\n  async uploadArticle({\n    token,\n    sectionId,\n    articleId,\n    title,\n    content,\n    labels,\n    locale,\n    isDraft,\n  }) {\n    const article = {\n      title,\n      body: content,\n      locale,\n      draft: isDraft,\n    };\n\n    if (articleId) {\n      // Update article\n      await request(token, {\n        method: 'PUT',\n        url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/articles/${articleId}/translations/${locale}.json`,\n        body: { translation: article },\n      });\n\n      // Add labels\n      if (labels) {\n        await request(token, {\n          method: 'PUT',\n          url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/articles/${articleId}.json`,\n          body: {\n            article: {\n              label_names: labels,\n            },\n          },\n        });\n      }\n      return articleId;\n    }\n\n    // Create new article\n    if (labels) {\n      article.label_names = labels;\n    }\n    const body = await request(token, {\n      method: 'POST',\n      url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/sections/${sectionId}/articles.json`,\n      body: { article },\n    });\n    return `${body.article.id}`;\n  },\n};\n"
  },
  {
    "path": "src/services/providers/wordpressProvider.js",
    "content": "import store from '../../store';\nimport wordpressHelper from './helpers/wordpressHelper';\nimport Provider from './common/Provider';\n\nexport default new Provider({\n  id: 'wordpress',\n  name: 'WordPress',\n  getToken({ sub }) {\n    return store.getters['data/wordpressTokensBySub'][sub];\n  },\n  getLocationUrl({ siteId, postId }) {\n    return `https://wordpress.com/post/${siteId}/${postId}`;\n  },\n  getLocationDescription({ postId }) {\n    return postId;\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const post = await wordpressHelper.uploadPost({\n      ...publishLocation,\n      ...metadata,\n      token,\n      content: html,\n    });\n    return {\n      ...publishLocation,\n      siteId: `${post.site_ID}`,\n      postId: `${post.ID}`,\n    };\n  },\n  makeLocation(token, domain, postId) {\n    const location = {\n      providerId: this.id,\n      sub: token.sub,\n      domain,\n    };\n    if (postId) {\n      location.postId = postId;\n    }\n    return location;\n  },\n});\n"
  },
  {
    "path": "src/services/providers/zendeskProvider.js",
    "content": "import store from '../../store';\nimport zendeskHelper from './helpers/zendeskHelper';\nimport Provider from './common/Provider';\n\nexport default new Provider({\n  id: 'zendesk',\n  name: 'Zendesk',\n  getToken({ sub }) {\n    return store.getters['data/zendeskTokensBySub'][sub];\n  },\n  getLocationUrl({ sub, locale, articleId }) {\n    const token = this.getToken({ sub });\n    return `https://${token.subdomain}.zendesk.com/hc/${locale}/articles/${articleId}`;\n  },\n  getLocationDescription({ articleId }) {\n    return articleId;\n  },\n  async publish(token, html, metadata, publishLocation) {\n    const articleId = await zendeskHelper.uploadArticle({\n      ...publishLocation,\n      token,\n      title: metadata.title,\n      content: html,\n      labels: metadata.tags,\n      isDraft: metadata.status === 'draft',\n    });\n    return {\n      ...publishLocation,\n      articleId,\n    };\n  },\n  makeLocation(token, sectionId, locale, articleId) {\n    const location = {\n      providerId: this.id,\n      sub: token.sub,\n      sectionId,\n      locale,\n    };\n    if (articleId) {\n      location.articleId = articleId;\n    }\n    return location;\n  },\n});\n"
  },
  {
    "path": "src/services/publishSvc.js",
    "content": "import localDbSvc from './localDbSvc';\nimport store from '../store';\nimport utils from './utils';\nimport networkSvc from './networkSvc';\nimport exportSvc from './exportSvc';\nimport providerRegistry from './providers/common/providerRegistry';\nimport workspaceSvc from './workspaceSvc';\nimport badgeSvc from './badgeSvc';\n\nconst hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length;\n\nconst loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)\n  // Item does not exist, create it\n  .catch(() => store.commit(`${type}/setItem`, {\n    id: `${fileId}/${type}`,\n  }));\nconst loadContent = loader('content');\n\nconst ensureArray = (value) => {\n  if (!value) {\n    return [];\n  }\n  if (!Array.isArray(value)) {\n    return `${value}`.trim().split(/\\s*,\\s*/);\n  }\n  return value;\n};\n\nconst ensureString = (value, defaultValue) => {\n  if (!value) {\n    return defaultValue;\n  }\n  return `${value}`;\n};\n\nconst ensureDate = (value, defaultValue) => {\n  if (!value) {\n    return defaultValue;\n  }\n  return new Date(`${value}`);\n};\n\nconst publish = async (publishLocation) => {\n  const { fileId } = publishLocation;\n  const template = store.getters['data/allTemplatesById'][publishLocation.templateId];\n  const html = await exportSvc.applyTemplate(fileId, template);\n  const content = await localDbSvc.loadItem(`${fileId}/content`);\n  const file = store.state.file.itemsById[fileId];\n  const properties = utils.computeProperties(content.properties);\n  const provider = providerRegistry.providersById[publishLocation.providerId];\n  const token = provider.getToken(publishLocation);\n  const metadata = {\n    title: ensureString(properties.title, file.name),\n    author: ensureString(properties.author),\n    tags: ensureArray(properties.tags),\n    categories: ensureArray(properties.categories),\n    excerpt: ensureString(properties.excerpt),\n    featuredImage: ensureString(properties.featuredImage),\n    status: ensureString(properties.status),\n    date: ensureDate(properties.date, new Date()),\n  };\n  return provider.publish(token, html, metadata, publishLocation);\n};\n\nconst publishFile = async (fileId) => {\n  let counter = 0;\n  await loadContent(fileId);\n  const publishLocations = [\n    ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [],\n  ];\n  try {\n    await utils.awaitSequence(publishLocations, async (publishLocation) => {\n      await store.dispatch('queue/doWithLocation', {\n        location: publishLocation,\n        action: async () => {\n          const publishLocationToStore = await publish(publishLocation);\n          try {\n            // Replace publish location if modified\n            if (utils.serializeObject(publishLocation) !==\n              utils.serializeObject(publishLocationToStore)\n            ) {\n              store.commit('publishLocation/patchItem', publishLocationToStore);\n              workspaceSvc.ensureUniqueLocations();\n            }\n            counter += 1;\n          } catch (err) {\n            if (store.state.offline) {\n              throw err;\n            }\n            console.error(err); // eslint-disable-line no-console\n            store.dispatch('notification/error', err);\n          }\n        },\n      });\n    });\n    const file = store.state.file.itemsById[fileId];\n    store.dispatch('notification/info', `\"${file.name}\" was published to ${counter} location(s).`);\n  } finally {\n    await localDbSvc.unloadContents();\n  }\n};\n\nconst requestPublish = () => {\n  // No publish in light mode\n  if (store.state.light) {\n    return;\n  }\n\n  store.dispatch('queue/enqueuePublishRequest', async () => {\n    let intervalId;\n    const attempt = async () => {\n      // Only start publishing when these conditions are met\n      if (networkSvc.isUserActive()) {\n        clearInterval(intervalId);\n        if (!hasCurrentFilePublishLocations()) {\n          // Cancel publish\n          throw new Error('Publish not possible.');\n        }\n        await publishFile(store.getters['file/current'].id);\n        badgeSvc.addBadge('triggerPublish');\n      }\n    };\n    intervalId = utils.setInterval(() => attempt(), 1000);\n    return attempt();\n  });\n};\n\nconst createPublishLocation = (publishLocation, featureId) => {\n  const currentFile = store.getters['file/current'];\n  publishLocation.fileId = currentFile.id;\n  store.dispatch(\n    'queue/enqueue',\n    async () => {\n      const publishLocationToStore = await publish(publishLocation);\n      workspaceSvc.addPublishLocation(publishLocationToStore);\n      store.dispatch('notification/info', `A new publication location was added to \"${currentFile.name}\".`);\n      if (featureId) {\n        badgeSvc.addBadge(featureId);\n      }\n    },\n  );\n};\n\nexport default {\n  requestPublish,\n  createPublishLocation,\n};\n"
  },
  {
    "path": "src/services/syncSvc.js",
    "content": "import localDbSvc from './localDbSvc';\nimport store from '../store';\nimport utils from './utils';\nimport diffUtils from './diffUtils';\nimport networkSvc from './networkSvc';\nimport providerRegistry from './providers/common/providerRegistry';\nimport googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';\nimport './providers/couchdbWorkspaceProvider';\nimport './providers/githubWorkspaceProvider';\nimport './providers/gitlabWorkspaceProvider';\nimport './providers/googleDriveWorkspaceProvider';\nimport tempFileSvc from './tempFileSvc';\nimport workspaceSvc from './workspaceSvc';\nimport constants from '../data/constants';\nimport badgeSvc from './badgeSvc';\n\nconst minAutoSyncEvery = 60 * 1000; // 60 sec\nconst inactivityThreshold = 3 * 1000; // 3 sec\nconst restartSyncAfter = 30 * 1000; // 30 sec\nconst restartContentSyncAfter = 1000; // Enough to detect an authorize pop up\nconst checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec\nconst maxContentHistory = 20;\n\nconst LAST_SEEN = 0;\nconst LAST_MERGED = 1;\nconst LAST_SENT = 2;\n\nlet actionProvider;\nlet workspaceProvider;\n\n/**\n * Use a lock in the local storage to prevent multiple windows concurrency.\n */\nlet lastSyncActivity;\nconst getLastStoredSyncActivity = () =>\n  parseInt(localStorage.getItem(store.getters['workspace/lastSyncActivityKey']), 10) || 0;\n\n/**\n * Return true if workspace sync is possible.\n */\nconst isWorkspaceSyncPossible = () => !!store.getters['workspace/syncToken'];\n\n/**\n * Return true if file has at least one explicit sync location.\n */\nconst hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length;\n\n/**\n * Return true if we are online and we have something to sync.\n */\nconst isSyncPossible = () => !store.state.offline &&\n  (isWorkspaceSyncPossible() || hasCurrentFileSyncLocations());\n\n/**\n * Return true if we are the many window, ie we have the lastSyncActivity lock.\n */\nconst isSyncWindow = () => {\n  const storedLastSyncActivity = getLastStoredSyncActivity();\n  return lastSyncActivity === storedLastSyncActivity ||\n    Date.now() > inactivityThreshold + storedLastSyncActivity;\n};\n\n/**\n * Return true if auto sync can start, ie if lastSyncActivity is old enough.\n */\nconst isAutoSyncReady = () => {\n  let { autoSyncEvery } = store.getters['data/computedSettings'];\n  if (autoSyncEvery < minAutoSyncEvery) {\n    autoSyncEvery = minAutoSyncEvery;\n  }\n  return Date.now() > autoSyncEvery + getLastStoredSyncActivity();\n};\n\n/**\n * Update the lastSyncActivity, assuming we have the lock.\n */\nconst setLastSyncActivity = () => {\n  const currentDate = Date.now();\n  lastSyncActivity = currentDate;\n  localStorage.setItem(store.getters['workspace/lastSyncActivityKey'], currentDate);\n};\n\n/**\n * Upgrade hashes if syncedContent is from an old version\n */\nconst upgradeSyncedContent = (syncedContent) => {\n  if (syncedContent.v) {\n    return syncedContent;\n  }\n  const hashUpgrades = {};\n  const historyData = {};\n  const syncHistory = {};\n  Object.entries(syncedContent.historyData).forEach(([hash, content]) => {\n    const newContent = utils.addItemHash(content);\n    historyData[newContent.hash] = newContent;\n    hashUpgrades[hash] = newContent.hash;\n  });\n  Object.entries(syncedContent.syncHistory).forEach(([id, hashEntries]) => {\n    syncHistory[id] = hashEntries.map(hash => hashUpgrades[hash]);\n  });\n  return {\n    ...syncedContent,\n    historyData,\n    syncHistory,\n    v: 1,\n  };\n};\n\n/**\n * Clean a syncedContent.\n */\nconst cleanSyncedContent = (syncedContent) => {\n  // Clean syncHistory from removed syncLocations\n  Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => {\n    if (syncLocationId !== 'main' && !store.state.syncLocation.itemsById[syncLocationId]) {\n      delete syncedContent.syncHistory[syncLocationId];\n    }\n  });\n\n  const allSyncLocationHashSet = new Set([]\n    .concat(...Object.keys(syncedContent.syncHistory)\n      .map(id => syncedContent.syncHistory[id])));\n\n  // Clean historyData from unused contents\n  Object.keys(syncedContent.historyData)\n    .map(hash => parseInt(hash, 10))\n    .forEach((hash) => {\n      if (!allSyncLocationHashSet.has(hash)) {\n        delete syncedContent.historyData[hash];\n      }\n    });\n};\n\n/**\n * Apply changes retrieved from the workspace provider. Update sync data accordingly.\n */\nconst applyChanges = (changes) => {\n  const allItemsById = { ...store.getters.allItemsById };\n  const syncDataById = { ...store.getters['data/syncDataById'] };\n  const idsToKeep = {};\n  let saveSyncData = false;\n  let getExistingItem;\n  if (store.getters['workspace/currentWorkspaceIsGit']) {\n    const itemsByGitPath = { ...store.getters.itemsByGitPath };\n    getExistingItem = existingSyncData => existingSyncData && itemsByGitPath[existingSyncData.id];\n  } else {\n    getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId];\n  }\n\n  // Process each change\n  changes.forEach((change) => {\n    const existingSyncData = syncDataById[change.syncDataId];\n    const existingItem = getExistingItem(existingSyncData);\n    // If item was removed\n    if (!change.item && existingSyncData) {\n      if (syncDataById[change.syncDataId]) {\n        delete syncDataById[change.syncDataId];\n        saveSyncData = true;\n      }\n      if (existingItem) {\n        // Remove object from the store\n        store.commit(`${existingItem.type}/deleteItem`, existingItem.id);\n        delete allItemsById[existingItem.id];\n      }\n    // If item was modified\n    } else if (change.item && change.item.hash) {\n      idsToKeep[change.item.id] = true;\n\n      if ((existingSyncData || {}).hash !== change.syncData.hash) {\n        syncDataById[change.syncDataId] = change.syncData;\n        saveSyncData = true;\n      }\n      if (\n        // If no sync data or existing one is different\n        (existingSyncData || {}).hash !== change.item.hash\n        // And no existing item or existing item is different\n        && (existingItem || {}).hash !== change.item.hash\n        // And item is not content nor data, which will be merged later\n        && change.item.type !== 'content' && change.item.type !== 'data'\n      ) {\n        store.commit(`${change.item.type}/setItem`, change.item);\n        allItemsById[change.item.id] = change.item;\n      }\n    }\n  });\n\n  if (saveSyncData) {\n    store.dispatch('data/setSyncDataById', syncDataById);\n\n    // Sanitize the workspace\n    workspaceSvc.sanitizeWorkspace(idsToKeep);\n  }\n};\n\n/**\n * Create a sync location by uploading the current file content.\n */\nconst createSyncLocation = (syncLocation) => {\n  const currentFile = store.getters['file/current'];\n  const fileId = currentFile.id;\n  syncLocation.fileId = fileId;\n  // Use deepCopy to freeze the item\n  const content = utils.deepCopy(store.getters['content/current']);\n  store.dispatch(\n    'queue/enqueue',\n    async () => {\n      const provider = providerRegistry.providersById[syncLocation.providerId];\n      const token = provider.getToken(syncLocation);\n      const updatedSyncLocation = await provider.uploadContent(token, {\n        ...content,\n        history: [content.hash],\n      }, syncLocation);\n      await localDbSvc.loadSyncedContent(fileId);\n      const newSyncedContent = utils.deepCopy(upgradeSyncedContent(store.state.syncedContent.itemsById[`${fileId}/syncedContent`]));\n      const newSyncHistoryItem = [];\n      newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;\n      newSyncHistoryItem[LAST_SEEN] = content.hash;\n      newSyncHistoryItem[LAST_SENT] = content.hash;\n      newSyncedContent.historyData[content.hash] = content;\n\n      store.commit('syncedContent/patchItem', newSyncedContent);\n      workspaceSvc.addSyncLocation(updatedSyncLocation);\n      store.dispatch('notification/info', `A new synchronized location was added to \"${currentFile.name}\".`);\n    },\n  );\n};\n\n/**\n * Prevent from sending new data too long after old data has been fetched.\n */\nconst tooLateChecker = (timeout) => {\n  const tooLateAfter = Date.now() + timeout;\n  return (cb) => {\n    if (tooLateAfter < Date.now()) {\n      throw new Error('TOO_LATE');\n    }\n    return cb();\n  };\n};\n\n/**\n * Return true if file is in the temp folder or is a welcome file.\n */\nconst isTempFile = (fileId) => {\n  const contentId = `${fileId}/content`;\n  if (store.getters['data/syncDataByItemId'][contentId]) {\n    // If file has already been synced, let's not consider it a temp file\n    return false;\n  }\n  const file = store.state.file.itemsById[fileId];\n  const content = store.state.content.itemsById[contentId];\n  if (!file || !content) {\n    return false;\n  }\n  if (file.parentId === 'temp') {\n    return true;\n  }\n  const locations = [\n    ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [],\n    ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [],\n  ];\n  if (locations.length) {\n    // If file has sync/publish locations, it's not a temp file\n    return false;\n  }\n  // Return true if it's a welcome file that has no discussion\n  const { welcomeFileHashes } = store.getters['data/localSettings'];\n  const hash = utils.hash(content.text);\n  const hasDiscussions = Object.keys(content.discussions).length;\n  return file.name === 'Welcome file' && welcomeFileHashes[hash] && !hasDiscussions;\n};\n\n/**\n * Patch sync data if some have changed in the result.\n */\nconst updateSyncData = (result) => {\n  [\n    result.syncData,\n    result.contentSyncData,\n    result.fileSyncData,\n  ].forEach((syncData) => {\n    if (syncData) {\n      const oldSyncData = store.getters['data/syncDataById'][syncData.id];\n      if (utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)) {\n        store.dispatch('data/patchSyncDataById', {\n          [syncData.id]: syncData,\n        });\n      }\n    }\n  });\n  return result;\n};\n\nclass SyncContext {\n  restartSkipContents = false;\n  attempted = {};\n}\n\n/**\n * Sync one file with all its locations.\n */\nconst syncFile = async (fileId, syncContext = new SyncContext()) => {\n  const contentId = `${fileId}/content`;\n  syncContext.attempted[contentId] = true;\n\n  await localDbSvc.loadSyncedContent(fileId);\n  try {\n    await localDbSvc.loadItem(contentId);\n  } catch (e) {\n    // Item may not exist if content has not been downloaded yet\n  }\n\n  const getSyncedContent = () => upgradeSyncedContent(store.state.syncedContent.itemsById[`${fileId}/syncedContent`]);\n  const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];\n\n  try {\n    if (isTempFile(fileId)) {\n      return;\n    }\n\n    const syncLocations = [\n      ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [],\n    ];\n    if (isWorkspaceSyncPossible()) {\n      syncLocations.unshift({ id: 'main', providerId: workspaceProvider.id, fileId });\n    }\n\n    await utils.awaitSequence(syncLocations, async (syncLocation) => {\n      const provider = providerRegistry.providersById[syncLocation.providerId];\n      if (!provider) {\n        return;\n      }\n      const token = provider.getToken(syncLocation);\n      if (!token) {\n        return;\n      }\n\n      const downloadContent = async () => {\n        // On simple provider, call simply downloadContent\n        if (syncLocation.id !== 'main') {\n          return provider.downloadContent(token, syncLocation);\n        }\n\n        // On workspace provider, call downloadWorkspaceContent\n        const oldContentSyncData = store.getters['data/syncDataByItemId'][contentId];\n        const oldFileSyncData = store.getters['data/syncDataByItemId'][fileId];\n        if (!oldContentSyncData || !oldFileSyncData) {\n          return null;\n        }\n\n        const { content } = updateSyncData(await provider.downloadWorkspaceContent({\n          token,\n          contentId,\n          contentSyncData: oldContentSyncData,\n          fileSyncData: oldFileSyncData,\n        }));\n\n        // Return the downloaded content\n        return content;\n      };\n\n      const uploadContent = async (content, ifNotTooLate) => {\n        // On simple provider, call simply uploadContent\n        if (syncLocation.id !== 'main') {\n          return provider.uploadContent(token, content, syncLocation, ifNotTooLate);\n        }\n\n        // On workspace provider, call uploadWorkspaceContent\n        const oldContentSyncData = store.getters['data/syncDataByItemId'][contentId];\n        if (oldContentSyncData && oldContentSyncData.hash === content.hash) {\n          return syncLocation;\n        }\n        const oldFileSyncData = store.getters['data/syncDataByItemId'][fileId];\n\n        updateSyncData(await provider.uploadWorkspaceContent({\n          token,\n          content,\n          // Use deepCopy to freeze item\n          file: utils.deepCopy(store.state.file.itemsById[fileId]),\n          contentSyncData: oldContentSyncData,\n          fileSyncData: oldFileSyncData,\n          ifNotTooLate,\n        }));\n\n        // Return syncLocation\n        return syncLocation;\n      };\n\n      const doSyncLocation = async () => {\n        const serverContent = await downloadContent(token, syncLocation);\n        const syncedContent = getSyncedContent();\n        const syncHistoryItem = getSyncHistoryItem(syncLocation.id);\n\n        // Merge content\n        let mergedContent;\n        const clientContent = utils.deepCopy(store.state.content.itemsById[contentId]);\n        if (!clientContent) {\n          mergedContent = utils.deepCopy(serverContent || null);\n        } else if (!serverContent // If sync location has not been created yet\n          // Or server and client contents are synced\n          || serverContent.hash === clientContent.hash\n          // Or server content has not changed or has already been merged\n          || syncedContent.historyData[serverContent.hash]\n        ) {\n          mergedContent = clientContent;\n        } else {\n          // Perform a merge with last merged content if any, or perform a simple fusion otherwise\n          let lastMergedContent = utils.someResult(\n            serverContent.history,\n            hash => syncedContent.historyData[hash],\n          );\n          if (!lastMergedContent && syncHistoryItem) {\n            lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];\n          }\n          mergedContent = diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);\n        }\n        if (!mergedContent) {\n          return;\n        }\n\n        // Update or set content in store\n        store.commit('content/setItem', {\n          id: contentId,\n          text: utils.sanitizeText(mergedContent.text),\n          properties: utils.sanitizeText(mergedContent.properties),\n          discussions: mergedContent.discussions,\n          comments: mergedContent.comments,\n        });\n\n        // Retrieve content with its new hash value and freeze it\n        mergedContent = utils.deepCopy(store.state.content.itemsById[contentId]);\n\n        // Make merged content history\n        const mergedContentHistory = serverContent ? serverContent.history.slice() : [];\n        let skipUpload = true;\n        if (mergedContentHistory[0] !== mergedContent.hash) {\n          // Put merged content hash at the beginning of history\n          mergedContentHistory.unshift(mergedContent.hash);\n          // Server content is either out of sync or its history is incomplete, do upload\n          skipUpload = false;\n        }\n        if (syncHistoryItem\n          && syncHistoryItem[LAST_SENT] != null\n          && syncHistoryItem[LAST_SENT] !== mergedContent.hash\n        ) {\n          // Clean up by removing the hash we've previously added\n          const idx = mergedContentHistory.lastIndexOf(syncHistoryItem[LAST_SENT]);\n          if (idx !== -1) {\n            mergedContentHistory.splice(idx, 1);\n          }\n        }\n\n        // Update synced content\n        const newSyncedContent = utils.deepCopy(syncedContent);\n        const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];\n        newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;\n        if (serverContent &&\n          (serverContent.hash === newSyncHistoryItem[LAST_SEEN] ||\n          serverContent.history.includes(newSyncHistoryItem[LAST_SEEN]))\n        ) {\n          // That's the 2nd time we've seen this content, trust it for future merges\n          newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_SEEN];\n        }\n        newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_MERGED] || null;\n        newSyncHistoryItem[LAST_SEEN] = mergedContent.hash;\n        newSyncHistoryItem[LAST_SENT] = skipUpload ? null : mergedContent.hash;\n        newSyncedContent.historyData[mergedContent.hash] = mergedContent;\n\n        // Clean synced content from unused revisions\n        cleanSyncedContent(newSyncedContent);\n        // Store synced content\n        store.commit('syncedContent/patchItem', newSyncedContent);\n\n        if (skipUpload) {\n          // Server content and merged content are equal, skip content upload\n          return;\n        }\n\n        // If content is to be created, schedule a restart to create the file as well\n        if (provider === workspaceProvider &&\n          !store.getters['data/syncDataByItemId'][fileId]\n        ) {\n          syncContext.restartSkipContents = true;\n        }\n\n        // Upload merged content\n        const item = {\n          ...mergedContent,\n          history: mergedContentHistory.slice(0, maxContentHistory),\n        };\n        const syncLocationToStore = await uploadContent(\n          item,\n          tooLateChecker(restartContentSyncAfter),\n        );\n\n        // Replace sync location if modified\n        if (utils.serializeObject(syncLocation) !==\n          utils.serializeObject(syncLocationToStore)\n        ) {\n          store.commit('syncLocation/patchItem', syncLocationToStore);\n          workspaceSvc.ensureUniqueLocations();\n        }\n      };\n\n      await store.dispatch('queue/doWithLocation', {\n        location: syncLocation,\n        action: async () => {\n          try {\n            await doSyncLocation();\n          } catch (err) {\n            if (store.state.offline || (err && err.message === 'TOO_LATE')) {\n              throw err;\n            }\n            console.error(err); // eslint-disable-line no-console\n            store.dispatch('notification/error', err);\n          }\n        },\n      });\n    });\n  } catch (err) {\n    if (err && err.message === 'TOO_LATE') {\n      // Restart sync\n      await syncFile(fileId, syncContext);\n    } else {\n      throw err;\n    }\n  } finally {\n    await localDbSvc.unloadContents();\n  }\n};\n\n/**\n * Sync a data item, typically settings, templates or workspaces.\n */\nconst syncDataItem = async (dataId) => {\n  const getItem = () => store.state.data.itemsById[dataId]\n    || store.state.data.lsItemsById[dataId];\n\n  const oldItem = getItem();\n  const oldSyncData = store.getters['data/syncDataByItemId'][dataId];\n  // Sync if item hash and syncData hash are out of sync\n  if (oldSyncData && oldItem && oldItem.hash === oldSyncData.hash) {\n    return;\n  }\n\n  const token = workspaceProvider.getToken();\n  const { item } = updateSyncData(await workspaceProvider.downloadWorkspaceData({\n    token,\n    syncData: oldSyncData,\n  }));\n\n  const serverItem = item;\n  const dataSyncData = store.getters['data/dataSyncDataById'][dataId];\n  const clientItem = utils.deepCopy(getItem());\n  let mergedItem = (() => {\n    if (!clientItem) {\n      return serverItem;\n    }\n    if (!serverItem) {\n      return clientItem;\n    }\n    if (!dataSyncData) {\n      return serverItem;\n    }\n    if (dataSyncData.hash !== serverItem.hash) {\n      // Server version has changed\n      if (dataSyncData.hash !== clientItem.hash && typeof clientItem.data === 'object') {\n        // Client version has changed as well, merge data objects\n        return {\n          ...clientItem,\n          data: diffUtils.mergeObjects(serverItem.data, clientItem.data),\n        };\n      }\n      return serverItem;\n    }\n    return clientItem;\n  })();\n\n  if (!mergedItem) {\n    return;\n  }\n\n  if (clientItem && dataId === 'workspaces') {\n    // Clean deleted workspaces\n    await Promise.all(Object.keys(clientItem.data)\n      .filter(id => !mergedItem.data[id])\n      .map(id => workspaceSvc.removeWorkspace(id)));\n  }\n\n  // Update item in store\n  store.commit('data/setItem', {\n    id: dataId,\n    ...mergedItem,\n  });\n\n  // Retrieve item with new `hash` and freeze it\n  mergedItem = utils.deepCopy(getItem());\n\n  // Upload merged data item if out of sync\n  if (!serverItem || serverItem.hash !== mergedItem.hash) {\n    updateSyncData(await workspaceProvider.uploadWorkspaceData({\n      token,\n      item: mergedItem,\n      syncData: store.getters['data/syncDataByItemId'][dataId],\n      ifNotTooLate: tooLateChecker(restartContentSyncAfter),\n    }));\n  }\n\n  // Copy sync data into data sync data\n  store.dispatch('data/patchDataSyncDataById', {\n    [dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),\n  });\n};\n\n/**\n * Sync the whole workspace with the main provider and the current file explicit locations.\n */\nconst syncWorkspace = async (skipContents = false) => {\n  try {\n    const workspace = store.getters['workspace/currentWorkspace'];\n    const syncContext = new SyncContext();\n\n    // Store the sub in the DB since it's not safely stored in the token\n    const syncToken = store.getters['workspace/syncToken'];\n    const localSettings = store.getters['data/localSettings'];\n    if (!localSettings.syncSub) {\n      store.dispatch('data/patchLocalSettings', {\n        syncSub: syncToken.sub,\n      });\n    } else if (localSettings.syncSub !== syncToken.sub) {\n      throw new Error('Synchronization failed due to token inconsistency.');\n    }\n\n    const changes = await workspaceProvider.getChanges();\n\n    // Apply changes\n    applyChanges(workspaceProvider.prepareChanges(changes));\n    workspaceProvider.onChangesApplied();\n\n    // Prevent from sending items too long after changes have been retrieved\n    const ifNotTooLate = tooLateChecker(restartSyncAfter);\n\n    // Find and save one item to save\n    await utils.awaitSome(() => ifNotTooLate(async () => {\n      const storeItemMap = {\n        ...store.state.file.itemsById,\n        ...store.state.folder.itemsById,\n        ...store.state.syncLocation.itemsById,\n        ...store.state.publishLocation.itemsById,\n        // Deal with contents and data later\n      };\n\n      const syncDataByItemId = store.getters['data/syncDataByItemId'];\n      const isGit = !!store.getters['workspace/currentWorkspaceIsGit'];\n      const [changedItem, syncDataToUpdate] = utils.someResult(\n        Object.entries(storeItemMap),\n        ([id, item]) => {\n          const syncData = syncDataByItemId[id];\n          if ((syncData && syncData.hash === item.hash)\n            // Add file/folder only if parent folder has been added\n            || (!isGit && storeItemMap[item.parentId] && !syncDataByItemId[item.parentId])\n            // Don't create folder if it's a git workspace\n            || (isGit && item.type === 'folder')\n            // Add file only if content has been added\n            || (item.type === 'file' && !syncDataByItemId[`${id}/content`])\n          ) {\n            return null;\n          }\n          return [item, syncData];\n        },\n      ) || [];\n\n      if (!changedItem) return false;\n\n      updateSyncData(await workspaceProvider.saveWorkspaceItem({\n        // Use deepCopy to freeze objects\n        item: utils.deepCopy(changedItem),\n        syncData: utils.deepCopy(syncDataToUpdate),\n        ifNotTooLate,\n      }));\n\n      return true;\n    }));\n\n    // Find and remove one item to remove\n    await utils.awaitSome(() => ifNotTooLate(async () => {\n      let getItem;\n      let getFileItem;\n      if (store.getters['workspace/currentWorkspaceIsGit']) {\n        const { itemsByGitPath } = store.getters;\n        getItem = syncData => itemsByGitPath[syncData.id];\n        getFileItem = syncData => itemsByGitPath[syncData.id.slice(1)]; // Remove leading /\n      } else {\n        const { allItemsById } = store.getters;\n        getItem = syncData => allItemsById[syncData.itemId];\n        getFileItem = syncData => allItemsById[syncData.itemId.split('/')[0]];\n      }\n\n      const syncDataById = store.getters['data/syncDataById'];\n      const syncDataToRemove = utils.deepCopy(utils.someResult(\n        Object.values(syncDataById),\n        (syncData) => {\n          if (getItem(syncData)\n            // We don't want to delete data items, especially on first sync\n            || syncData.type === 'data'\n            // Remove content only if file has been removed\n            || (syncData.type === 'content' && getFileItem(syncData))\n          ) {\n            return null;\n          }\n          return syncData;\n        },\n      ));\n\n      if (!syncDataToRemove) return false;\n\n      await workspaceProvider.removeWorkspaceItem({\n        syncData: syncDataToRemove,\n        ifNotTooLate,\n      });\n      const syncDataByIdCopy = { ...store.getters['data/syncDataById'] };\n      delete syncDataByIdCopy[syncDataToRemove.id];\n      store.dispatch('data/setSyncDataById', syncDataByIdCopy);\n      return true;\n    }));\n\n    // Sync settings, workspaces and badges only in the main workspace\n    if (workspace.id === 'main') {\n      await syncDataItem('settings');\n      await syncDataItem('workspaces');\n      await syncDataItem('badgeCreations');\n    }\n    await syncDataItem('templates');\n\n    if (!skipContents) {\n      const currentFileId = store.getters['file/current'].id;\n      if (currentFileId) {\n        // Sync current file first\n        await syncFile(currentFileId, syncContext);\n      }\n\n      // Find and sync one file out of sync\n      await utils.awaitSome(async () => {\n        let getSyncData;\n        if (store.getters['workspace/currentWorkspaceIsGit']) {\n          const { gitPathsByItemId } = store.getters;\n          const syncDataById = store.getters['data/syncDataById'];\n          getSyncData = contentId => syncDataById[gitPathsByItemId[contentId]];\n        } else {\n          const syncDataByItemId = store.getters['data/syncDataByItemId'];\n          getSyncData = contentId => syncDataByItemId[contentId];\n        }\n\n        // Collect all [fileId, contentId]\n        const ids = [\n          ...Object.keys(localDbSvc.hashMap.content)\n            .map(contentId => [contentId.split('/')[0], contentId]),\n          ...store.getters['file/items']\n            .map(file => [file.id, `${file.id}/content`]),\n        ];\n\n        // Find the first content out of sync\n        const contentMap = store.state.content.itemsById;\n        const fileIdToSync = utils.someResult(ids, ([fileId, contentId]) => {\n          // Get the content hash from itemsById or from localDbSvc if not loaded\n          const loadedContent = contentMap[contentId];\n          const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];\n          const syncData = getSyncData(contentId);\n          if (\n            // Sync if content syncing was not attempted yet\n            !syncContext.attempted[contentId] &&\n            // And if syncData does not exist or if content hash and syncData hash are inconsistent\n            (!syncData || syncData.hash !== hash)\n          ) {\n            return fileId;\n          }\n          return null;\n        });\n\n        if (!fileIdToSync) return false;\n\n        await syncFile(fileIdToSync, syncContext);\n        return true;\n      });\n    }\n\n    // Restart sync if requested\n    if (syncContext.restartSkipContents) {\n      await syncWorkspace(true);\n    }\n\n    if (workspace.id === 'main') {\n      badgeSvc.addBadge('syncMainWorkspace');\n    }\n  } catch (err) {\n    if (err && err.message === 'TOO_LATE') {\n      // Restart sync\n      await syncWorkspace();\n    } else {\n      throw err;\n    }\n  }\n};\n\n/**\n * Enqueue a sync task, if possible.\n */\nconst requestSync = (addTriggerSyncBadge = false) => {\n  // No sync in light mode\n  if (store.state.light) {\n    return;\n  }\n\n  store.dispatch('queue/enqueueSyncRequest', async () => {\n    let intervalId;\n    const attempt = async () => {\n      // Only start syncing when these conditions are met\n      if (networkSvc.isUserActive() && isSyncWindow()) {\n        clearInterval(intervalId);\n        if (!isSyncPossible()) {\n          // Cancel sync\n          throw new Error('Sync not possible.');\n        }\n\n        // Determine if we have to clean files\n        const fileHashesToClean = {};\n        if (getLastStoredSyncActivity() + constants.cleanTrashAfter < Date.now()) {\n          // Last synchronization happened 7 days ago\n          const syncDataByItemId = store.getters['data/syncDataByItemId'];\n          store.getters['file/items'].forEach((file) => {\n            // If file is in the trash and has not been modified since it was last synced\n            const syncData = syncDataByItemId[file.id];\n            if (syncData && file.parentId === 'trash' && file.hash === syncData.hash) {\n              fileHashesToClean[file.id] = file.hash;\n            }\n          });\n        }\n\n        // Call setLastSyncActivity periodically\n        intervalId = utils.setInterval(() => setLastSyncActivity(), 1000);\n        setLastSyncActivity();\n\n        try {\n          if (isWorkspaceSyncPossible()) {\n            await syncWorkspace();\n          } else if (hasCurrentFileSyncLocations()) {\n            // Only sync the current file if workspace sync is unavailable\n            // as we don't want to look for out-of-sync files by loading\n            // all the syncedContent objects.\n            await syncFile(store.getters['file/current'].id);\n          }\n\n          // Clean files\n          Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => {\n            const file = store.state.file.itemsById[fileId];\n            if (file && file.hash === fileHash) {\n              workspaceSvc.deleteFile(fileId);\n            }\n          });\n\n          if (addTriggerSyncBadge) {\n            badgeSvc.addBadge('triggerSync');\n          }\n        } finally {\n          clearInterval(intervalId);\n        }\n      }\n    };\n\n    intervalId = utils.setInterval(() => attempt(), 1000);\n    return attempt();\n  });\n};\n\nexport default {\n  async init() {\n    // Load workspaces and tokens from localStorage\n    localDbSvc.syncLocalStorage();\n\n    // Try to find a suitable action provider\n    actionProvider = providerRegistry.providersById[utils.queryParams.providerId];\n    if (actionProvider && actionProvider.initAction) {\n      await actionProvider.initAction();\n    }\n\n    // Try to find a suitable workspace sync provider\n    workspaceProvider = providerRegistry.providersById[utils.queryParams.providerId];\n    if (!workspaceProvider || !workspaceProvider.initWorkspace) {\n      workspaceProvider = googleDriveAppDataProvider;\n    }\n    const workspace = await workspaceProvider.initWorkspace();\n    // Fix the URL hash\n    const { paymentSuccess } = utils.queryParams;\n    utils.setQueryParams(workspaceProvider.getWorkspaceParams(workspace));\n\n    store.dispatch('workspace/setCurrentWorkspaceId', workspace.id);\n    await localDbSvc.init();\n\n    // Enable sponsorship\n    if (paymentSuccess) {\n      store.dispatch('modal/open', 'paymentSuccess')\n        .catch(() => { /* Cancel */ });\n      const sponsorToken = store.getters['workspace/sponsorToken'];\n      // Force check sponsorship after a few seconds\n      const currentDate = Date.now();\n      if (sponsorToken && sponsorToken.expiresOn > currentDate - checkSponsorshipAfter) {\n        store.dispatch('data/addGoogleToken', {\n          ...sponsorToken,\n          expiresOn: currentDate - checkSponsorshipAfter,\n        });\n      }\n    }\n\n    // Try to find a suitable action provider\n    actionProvider = providerRegistry.providersById[utils.queryParams.providerId] || actionProvider;\n    if (actionProvider && actionProvider.performAction) {\n      const newSyncLocation = await actionProvider.performAction();\n      if (newSyncLocation) {\n        this.createSyncLocation(newSyncLocation);\n      }\n    }\n\n    await tempFileSvc.init();\n\n    if (!store.state.light) {\n      // Sync periodically\n      utils.setInterval(() => {\n        if (isSyncPossible()\n          && networkSvc.isUserActive()\n          && isSyncWindow()\n          && isAutoSyncReady()\n        ) {\n          requestSync();\n        }\n      }, 1000);\n\n      // Unload contents from memory periodically\n      utils.setInterval(() => {\n        // Wait for sync and publish to finish\n        if (store.state.queue.isEmpty) {\n          localDbSvc.unloadContents();\n        }\n      }, 5000);\n    }\n  },\n  isSyncPossible,\n  requestSync,\n  createSyncLocation,\n};\n"
  },
  {
    "path": "src/services/tempFileSvc.js",
    "content": "import cledit from './editor/cledit';\nimport store from '../store';\nimport utils from './utils';\nimport editorSvc from './editorSvc';\nimport workspaceSvc from './workspaceSvc';\n\nconst {\n  origin,\n  fileName,\n  contentText,\n  contentProperties,\n} = utils.queryParams;\nconst isLight = origin && window.parent;\n\nexport default {\n  setReady() {\n    if (isLight) {\n      // Wait for the editor to init\n      setTimeout(() => window.parent.postMessage({ type: 'ready' }, origin), 1);\n    }\n  },\n  closed: false,\n  close() {\n    if (isLight) {\n      if (!this.closed) {\n        window.parent.postMessage({ type: 'close' }, origin);\n      }\n      this.closed = true;\n    }\n  },\n  async init() {\n    if (!isLight) {\n      return;\n    }\n    store.commit('setLight', true);\n\n    const file = await workspaceSvc.createFile({\n      name: fileName || utils.getHostname(origin),\n      text: contentText || '\\n',\n      properties: contentProperties,\n      parentId: 'temp',\n    }, true);\n\n    // Sanitize file creations\n    const lastCreated = {};\n    const fileItemsById = store.state.file.itemsById;\n    Object.entries(store.getters['data/lastCreated']).forEach(([id, value]) => {\n      if (fileItemsById[id] && fileItemsById[id].parentId === 'temp') {\n        lastCreated[id] = value;\n      }\n    });\n\n    // Track file creation from other site\n    lastCreated[file.id] = {\n      created: Date.now(),\n    };\n\n    // Keep only the last 10 temp files created by other sites\n    Object.entries(lastCreated)\n      .sort(([, value1], [, value2]) => value2.created - value1.created)\n      .splice(10)\n      .forEach(([id]) => {\n        delete lastCreated[id];\n        workspaceSvc.deleteFile(id);\n      });\n\n    // Store file creations and open the file\n    store.dispatch('data/setLastCreated', lastCreated);\n    store.commit('file/setCurrentId', file.id);\n\n    const onChange = cledit.Utils.debounce(() => {\n      const currentFile = store.getters['file/current'];\n      if (currentFile.id !== file.id) {\n        // Close editor if file has changed for some reason\n        this.close();\n      } else if (!this.closed && editorSvc.previewCtx.html != null) {\n        const content = store.getters['content/current'];\n        const properties = utils.computeProperties(content.properties);\n        window.parent.postMessage({\n          type: 'fileChange',\n          payload: {\n            id: file.id,\n            name: currentFile.name,\n            content: {\n              text: content.text.slice(0, -1), // Remove trailing LF\n              properties,\n              yamlProperties: content.properties,\n              html: editorSvc.previewCtx.html,\n            },\n          },\n        }, origin);\n      }\n    }, 25);\n\n    // Watch preview refresh and file name changes\n    editorSvc.$on('previewCtx', onChange);\n    store.watch(() => store.getters['file/current'].name, onChange);\n  },\n};\n"
  },
  {
    "path": "src/services/templateWorker.js",
    "content": "// This WebWorker provides a safe environment to run user scripts\n// See http://stackoverflow.com/questions/10653809/making-webworkers-a-safe-environment/10796616\n\nimport Handlebars from 'handlebars';\n\n// Classeur own helpers\nHandlebars.registerHelper('tocToHtml', (toc, depth = 6) => {\n  function arrayToHtml(arr) {\n    if (!arr || !arr.length || arr[0].level > depth) {\n      return '';\n    }\n    const ulHtml = arr.map((item) => {\n      let result = '<li>';\n      if (item.anchor && item.title) {\n        result += `<a href=\"#${item.anchor}\">${item.title}</a>`;\n      }\n      result += arrayToHtml(item.children);\n      return `${result}</li>`;\n    }).join('\\n');\n    return `\\n<ul>\\n${ulHtml}\\n</ul>\\n`;\n  }\n  return new Handlebars.SafeString(arrayToHtml(toc));\n});\n\nconst whiteList = {\n  self: 1,\n  onmessage: 1,\n  postMessage: 1,\n  global: 1,\n  whiteList: 1,\n  eval: 1,\n  Array: 1,\n  Boolean: 1,\n  Date: 1,\n  Function: 1,\n  Number: 1,\n  Object: 1,\n  RegExp: 1,\n  String: 1,\n  Error: 1,\n  EvalError: 1,\n  RangeError: 1,\n  ReferenceError: 1,\n  SyntaxError: 1,\n  TypeError: 1,\n  URIError: 1,\n  decodeURI: 1,\n  decodeURIComponent: 1,\n  encodeURI: 1,\n  encodeURIComponent: 1,\n  isFinite: 1,\n  isNaN: 1,\n  parseFloat: 1,\n  parseInt: 1,\n  Infinity: 1,\n  JSON: 1,\n  Math: 1,\n  NaN: 1,\n  undefined: 1,\n  safeEval: 1,\n  close: 1,\n};\n\n/* eslint-disable no-restricted-globals */\nlet global = self;\nwhile (global !== Object.prototype) {\n  Object.getOwnPropertyNames(global).forEach((prop) => { // eslint-disable-line no-loop-func\n    if (!Object.prototype.hasOwnProperty.call(whiteList, prop)) {\n      try {\n        Object.defineProperty(global, prop, {\n          get() {\n            throw new Error(`Security Exception: cannot access ${prop}`);\n          },\n          configurable: false,\n        });\n      } catch (e) {\n        // Ignore\n      }\n    }\n  });\n  global = Object.getPrototypeOf(global);\n}\nself.Handlebars = Handlebars;\n\nfunction safeEval(code) {\n  eval(`\"use strict\";\\n${code}`); // eslint-disable-line no-eval\n}\n\nself.onmessage = (evt) => {\n  try {\n    const template = Handlebars.compile(evt.data[0]);\n    const context = evt.data[1];\n    safeEval(evt.data[2]);\n    self.postMessage([null, template(context)]);\n  } catch (err) {\n    self.postMessage([`${err}`]);\n  }\n  close();\n};\n"
  },
  {
    "path": "src/services/timeSvc.js",
    "content": "// Credit: https://github.com/github/time-elements/\nconst weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];\nconst months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\nconst pad = num => `0${num}`.slice(-2);\n\nfunction strftime(time, formatString) {\n  const day = time.getDay();\n  const date = time.getDate();\n  const month = time.getMonth();\n  const year = time.getFullYear();\n  const hour = time.getHours();\n  const minute = time.getMinutes();\n  const second = time.getSeconds();\n  return formatString.replace(/%([%aAbBcdeHIlmMpPSwyYZz])/g, (_arg) => {\n    let match;\n    const modifier = _arg[1];\n    switch (modifier) {\n      case '%':\n      default:\n        return '%';\n      case 'a':\n        return weekdays[day].slice(0, 3);\n      case 'A':\n        return weekdays[day];\n      case 'b':\n        return months[month].slice(0, 3);\n      case 'B':\n        return months[month];\n      case 'c':\n        return time.toString();\n      case 'd':\n        return pad(date);\n      case 'e':\n        return date;\n      case 'H':\n        return pad(hour);\n      case 'I':\n        return pad(strftime(time, '%l'));\n      case 'l':\n        return hour === 0 || hour === 12 ? 12 : (hour + 12) % 12;\n      case 'm':\n        return pad(month + 1);\n      case 'M':\n        return pad(minute);\n      case 'p':\n        return hour > 11 ? 'PM' : 'AM';\n      case 'P':\n        return hour > 11 ? 'pm' : 'am';\n      case 'S':\n        return pad(second);\n      case 'w':\n        return day;\n      case 'y':\n        return pad(year % 100);\n      case 'Y':\n        return year;\n      case 'Z':\n        match = time.toString().match(/\\((\\w+)\\)$/);\n        return match ? match[1] : '';\n      case 'z':\n        match = time.toString().match(/\\w([+-]\\d\\d\\d\\d) /);\n        return match ? match[1] : '';\n    }\n  });\n}\n\nlet dayFirst = null;\nlet yearSeparator = null;\n\n// Private: Determine if the day should be formatted before the month name in\n// the user's current locale. For example, `9 Jun` for en-GB and `Jun 9`\n// for en-US.\n//\n// Returns true if the day appears before the month.\nfunction isDayFirst() {\n  if (dayFirst !== null) {\n    return dayFirst;\n  }\n\n  if (!('Intl' in window)) {\n    return false;\n  }\n\n  const options = { day: 'numeric', month: 'short' };\n  const formatter = new window.Intl.DateTimeFormat(undefined, options);\n  const output = formatter.format(new Date(0));\n\n  dayFirst = !!output.match(/^\\d/);\n  return dayFirst;\n}\n\n// Private: Determine if the year should be separated from the month and day\n// with a comma. For example, `9 Jun 2014` in en-GB and `Jun 9, 2014` in en-US.\n//\n// Returns true if the date needs a separator.\nfunction isYearSeparator() {\n  if (yearSeparator !== null) {\n    return yearSeparator;\n  }\n\n  if (!('Intl' in window)) {\n    return true;\n  }\n\n  const options = { day: 'numeric', month: 'short', year: 'numeric' };\n  const formatter = new window.Intl.DateTimeFormat(undefined, options);\n  const output = formatter.format(new Date(0));\n\n  yearSeparator = !!output.match(/\\d,/);\n  return yearSeparator;\n}\n\n// Private: Determine if the date occurs in the same year as today's date.\n//\n// date - The Date to test.\n//\n// Returns true if it's this year.\nfunction isThisYear(date) {\n  const now = new Date();\n  return now.getUTCFullYear() === date.getUTCFullYear();\n}\n\nclass RelativeTime {\n  constructor(date) {\n    this.date = date;\n  }\n\n  toString() {\n    const ago = this.timeElapsed();\n    return ago || `on ${this.formatDate()}`;\n  }\n\n  timeElapsed() {\n    const ms = new Date().getTime() - this.date.getTime();\n    const sec = Math.round(ms / 1000);\n    const min = Math.round(sec / 60);\n    const hr = Math.round(min / 60);\n    const day = Math.round(hr / 24);\n    if (ms < 0) {\n      return 'just now';\n    } else if (sec < 45) {\n      return 'just now';\n    } else if (sec < 90) {\n      return 'a minute ago';\n    } else if (min < 45) {\n      return `${min} minutes ago`;\n    } else if (min < 90) {\n      return 'an hour ago';\n    } else if (hr < 24) {\n      return `${hr} hours ago`;\n    } else if (hr < 36) {\n      return 'a day ago';\n    } else if (day < 30) {\n      return `${day} days ago`;\n    }\n    return null;\n  }\n\n  formatDate() {\n    let format = isDayFirst() ? '%e %b' : '%b %e';\n    if (!isThisYear(this.date)) {\n      format += isYearSeparator() ? ', %Y' : ' %Y';\n    }\n    return strftime(this.date, format);\n  }\n}\n\nexport default {\n  format(time) {\n    return time && new RelativeTime(new Date(time)).toString();\n  },\n};\n"
  },
  {
    "path": "src/services/userSvc.js",
    "content": "import store from '../store';\nimport utils from './utils';\n\nconst refreshUserInfoAfter = 60 * 60 * 1000; // 60 minutes\n\nconst infoResolversByType = {};\nconst subPrefixesByType = {};\nconst typesBySubPrefix = {};\n\nconst lastInfosByUserId = {};\nconst infoPromisedByUserId = {};\n\nconst sanitizeUserId = (userId) => {\n  const prefix = userId[2] === ':' && userId.slice(0, 2);\n  if (typesBySubPrefix[prefix]) {\n    return userId;\n  }\n  return `go:${userId}`;\n};\n\nconst parseUserId = userId => [typesBySubPrefix[userId.slice(0, 2)], userId.slice(3)];\n\nconst refreshUserInfos = () => {\n  if (store.state.offline) {\n    return;\n  }\n\n  Object.entries(lastInfosByUserId)\n    .filter(([userId, lastInfo]) => lastInfo === 0 && !infoPromisedByUserId[userId])\n    .forEach(async ([userId]) => {\n      const [type, sub] = parseUserId(userId);\n      const infoResolver = infoResolversByType[type];\n      if (infoResolver) {\n        try {\n          infoPromisedByUserId[userId] = true;\n          const userInfo = await infoResolver(sub);\n          store.commit('userInfo/setItem', userInfo);\n        } finally {\n          infoPromisedByUserId[userId] = false;\n          lastInfosByUserId[userId] = Date.now();\n        }\n      }\n    });\n};\n\nexport default {\n  setInfoResolver(type, subPrefix, resolver) {\n    infoResolversByType[type] = resolver;\n    subPrefixesByType[type] = subPrefix;\n    typesBySubPrefix[subPrefix] = type;\n  },\n  getCurrentUserId() {\n    const loginToken = store.getters['workspace/loginToken'];\n    if (!loginToken) {\n      return null;\n    }\n    const loginType = store.getters['workspace/loginType'];\n    const prefix = subPrefixesByType[loginType];\n    return prefix ? `${prefix}:${loginToken.sub}` : loginToken.sub;\n  },\n  sanitizeUserId,\n  addUserInfo(userInfo) {\n    store.commit('userInfo/setItem', userInfo);\n    lastInfosByUserId[userInfo.id] = Date.now();\n  },\n  addUserId(userId) {\n    if (userId) {\n      const sanitizedUserId = sanitizeUserId(userId);\n      const lastInfo = lastInfosByUserId[sanitizedUserId];\n      if (lastInfo === undefined) {\n        // Try to find a token with this sub to resolve name as soon as possible\n        const [type, sub] = parseUserId(sanitizedUserId);\n        const token = store.getters['data/tokensByType'][type][sub];\n        if (token) {\n          store.commit('userInfo/setItem', {\n            id: sanitizedUserId,\n            name: token.name,\n          });\n        }\n      }\n\n      if (lastInfo === undefined || lastInfo + refreshUserInfoAfter < Date.now()) {\n        lastInfosByUserId[sanitizedUserId] = 0;\n        refreshUserInfos();\n      }\n    }\n  },\n};\n\n// Get user info periodically\nutils.setInterval(() => refreshUserInfos(), 60 * 1000);\n"
  },
  {
    "path": "src/services/utils.js",
    "content": "import yaml from 'js-yaml';\nimport '../libs/clunderscore';\nimport presets from '../data/presets';\nimport constants from '../data/constants';\n\n// For utils.uid()\nconst uidLength = 16;\nconst crypto = window.crypto || window.msCrypto;\nconst alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');\nconst radix = alphabet.length;\nconst array = new Uint32Array(uidLength);\n\n// For utils.parseQueryParams()\nconst parseQueryParams = (params) => {\n  const result = {};\n  params.split('&').forEach((param) => {\n    const [key, value] = param.split('=').map(decodeURIComponent);\n    if (key && value != null) {\n      result[key] = value;\n    }\n  });\n  return result;\n};\n\n// For utils.setQueryParams()\nconst filterParams = (params = {}) => {\n  const result = {};\n  Object.entries(params).forEach(([key, value]) => {\n    if (key && value != null) {\n      result[key] = value;\n    }\n  });\n  return result;\n};\n\n// For utils.computeProperties()\nconst deepOverride = (obj, opt) => {\n  if (obj === undefined) {\n    return opt;\n  }\n  const objType = Object.prototype.toString.call(obj);\n  const optType = Object.prototype.toString.call(opt);\n  if (objType !== optType) {\n    return obj;\n  }\n  if (objType !== '[object Object]') {\n    return opt === undefined ? obj : opt;\n  }\n  Object.keys({\n    ...obj,\n    ...opt,\n  }).forEach((key) => {\n    obj[key] = deepOverride(obj[key], opt[key]);\n  });\n  return obj;\n};\n\n// For utils.addQueryParams()\nconst urlParser = document.createElement('a');\n\nconst deepCopy = (obj) => {\n  if (obj == null) {\n    return obj;\n  }\n  return JSON.parse(JSON.stringify(obj));\n};\n\n// Compute presets\nconst computedPresets = {};\nObject.keys(presets).forEach((key) => {\n  let preset = deepCopy(presets[key][0]);\n  if (presets[key][1]) {\n    preset = deepOverride(preset, presets[key][1]);\n  }\n  computedPresets[key] = preset;\n});\n\nexport default {\n  computedPresets,\n  queryParams: parseQueryParams(window.location.hash.slice(1)),\n  setQueryParams(params = {}) {\n    this.queryParams = filterParams(params);\n    const serializedParams = Object.entries(this.queryParams).map(([key, value]) =>\n      `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');\n    const hash = `#${serializedParams}`;\n    if (window.location.hash !== hash) {\n      window.location.replace(hash);\n    }\n  },\n  sanitizeText(text) {\n    const result = `${text || ''}`.slice(0, constants.textMaxLength);\n    // last char must be a `\\n`.\n    return `${result}\\n`.replace(/\\n\\n$/, '\\n');\n  },\n  sanitizeName(name) {\n    return `${name || ''}`\n      // Keep only 250 characters\n      .slice(0, 250) || constants.defaultName;\n  },\n  sanitizeFilename(name) {\n    return this.sanitizeName(`${name || ''}`\n      // Replace `/`, control characters and other kind of spaces with a space\n      .replace(/[/\\x00-\\x1F\\x7f-\\xa0\\s]+/g, ' ') // eslint-disable-line no-control-regex\n      .trim()) || constants.defaultName;\n  },\n  deepCopy,\n  serializeObject(obj) {\n    return obj === undefined ? obj : JSON.stringify(obj, (key, value) => {\n      if (Object.prototype.toString.call(value) !== '[object Object]') {\n        return value;\n      }\n      // Sort keys to have a predictable result\n      return Object.keys(value).sort().reduce((sorted, valueKey) => {\n        sorted[valueKey] = value[valueKey];\n        return sorted;\n      }, {});\n    });\n  },\n  search(items, criteria) {\n    let result;\n    items.some((item) => {\n      // If every field fits the criteria\n      if (Object.entries(criteria).every(([key, value]) => value === item[key])) {\n        result = item;\n      }\n      return result;\n    });\n    return result;\n  },\n  uid() {\n    crypto.getRandomValues(array);\n    return array.cl_map(value => alphabet[value % radix]).join('');\n  },\n  hash(str) {\n    // https://stackoverflow.com/a/7616484/1333165\n    let hash = 0;\n    if (!str) return hash;\n    for (let i = 0; i < str.length; i += 1) {\n      const char = str.charCodeAt(i);\n      hash = ((hash << 5) - hash) + char; // eslint-disable-line no-bitwise\n      hash |= 0; // eslint-disable-line no-bitwise\n    }\n    return hash;\n  },\n  getItemHash(item) {\n    return this.hash(this.serializeObject({\n      ...item,\n      // These properties must not be part of the hash\n      id: undefined,\n      hash: undefined,\n      history: undefined,\n    }));\n  },\n  addItemHash(item) {\n    return {\n      ...item,\n      hash: this.getItemHash(item),\n    };\n  },\n  makeWorkspaceId(params) {\n    return Math.abs(this.hash(this.serializeObject(params))).toString(36);\n  },\n  getDbName(workspaceId) {\n    let dbName = 'stackedit-db';\n    if (workspaceId !== 'main') {\n      dbName += `-${workspaceId}`;\n    }\n    return dbName;\n  },\n  encodeBase64(str, urlSafe = false) {\n    const uriEncodedStr = encodeURIComponent(str);\n    const utf8Str = uriEncodedStr.replace(\n      /%([0-9A-F]{2})/g,\n      (match, p1) => String.fromCharCode(`0x${p1}`),\n    );\n    const result = btoa(utf8Str);\n    if (!urlSafe) {\n      return result;\n    }\n    return result\n      .replace(/\\//g, '_') // Replace `/` with `_`\n      .replace(/\\+/g, '-') // Replace `+` with `-`\n      .replace(/=+$/, ''); // Remove trailing `=`\n  },\n  decodeBase64(str) {\n    // In case of URL safe base64\n    const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+');\n    const utf8Str = atob(sanitizedStr);\n    const uriEncodedStr = utf8Str\n      .split('')\n      .map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)\n      .join('');\n    return decodeURIComponent(uriEncodedStr);\n  },\n  computeProperties(yamlProperties) {\n    let properties = {};\n    try {\n      properties = yaml.safeLoad(yamlProperties) || {};\n    } catch (e) {\n      // Ignore\n    }\n    const extensions = properties.extensions || {};\n    const computedPreset = deepCopy(computedPresets[extensions.preset] || computedPresets.default);\n    const computedExtensions = deepOverride(computedPreset, properties.extensions);\n    computedExtensions.preset = extensions.preset;\n    properties.extensions = computedExtensions;\n    return properties;\n  },\n  randomize(value) {\n    return Math.floor((1 + (Math.random() * 0.2)) * value);\n  },\n  setInterval(func, interval) {\n    return setInterval(() => func(), this.randomize(interval));\n  },\n  async awaitSequence(values, asyncFunc) {\n    const results = [];\n    const valuesLeft = values.slice().reverse();\n    const runWithNextValue = async () => {\n      if (!valuesLeft.length) {\n        return results;\n      }\n      results.push(await asyncFunc(valuesLeft.pop()));\n      return runWithNextValue();\n    };\n    return runWithNextValue();\n  },\n  async awaitSome(asyncFunc) {\n    if (await asyncFunc()) {\n      return this.awaitSome(asyncFunc);\n    }\n    return null;\n  },\n  someResult(values, func) {\n    let result;\n    values.some((value) => {\n      result = func(value);\n      return result;\n    });\n    return result;\n  },\n  parseQueryParams,\n  addQueryParams(url = '', params = {}, hash = false) {\n    const keys = Object.keys(params).filter(key => params[key] != null);\n    urlParser.href = url;\n    if (!keys.length) {\n      return urlParser.href;\n    }\n    const serializedParams = keys.map(key =>\n      `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');\n    if (hash) {\n      if (urlParser.hash) {\n        urlParser.hash += '&';\n      } else {\n        urlParser.hash = '#';\n      }\n      urlParser.hash += serializedParams;\n    } else {\n      if (urlParser.search) {\n        urlParser.search += '&';\n      } else {\n        urlParser.search = '?';\n      }\n      urlParser.search += serializedParams;\n    }\n    return urlParser.href;\n  },\n  resolveUrl(baseUrl, path) {\n    const oldBaseElt = document.getElementsByTagName('base')[0];\n    const oldHref = oldBaseElt && oldBaseElt.href;\n    const newBaseElt = oldBaseElt || document.head.appendChild(document.createElement('base'));\n    newBaseElt.href = baseUrl;\n    urlParser.href = path;\n    const result = urlParser.href;\n    if (oldBaseElt) {\n      oldBaseElt.href = oldHref;\n    } else {\n      document.head.removeChild(newBaseElt);\n    }\n    return result;\n  },\n  getHostname(url) {\n    urlParser.href = url;\n    return urlParser.hostname;\n  },\n  encodeUrlPath(path) {\n    return path ? path.split('/').map(encodeURIComponent).join('/') : '';\n  },\n  parseGithubRepoUrl(url) {\n    const parsedRepo = url && url.match(/([^/:]+)\\/([^/]+?)(?:\\.git|\\/)?$/);\n    return parsedRepo && {\n      owner: parsedRepo[1],\n      repo: parsedRepo[2],\n    };\n  },\n  parseGitlabProjectPath(url) {\n    const parsedProject = url && url.match(/^https:\\/\\/[^/]+\\/(.+?)(?:\\.git|\\/)?$/);\n    return parsedProject && parsedProject[1];\n  },\n  createHiddenIframe(url) {\n    const iframeElt = document.createElement('iframe');\n    iframeElt.style.position = 'absolute';\n    iframeElt.style.left = '-99px';\n    iframeElt.style.width = '1px';\n    iframeElt.style.height = '1px';\n    iframeElt.src = url;\n    return iframeElt;\n  },\n  wrapRange(range, eltProperties) {\n    const rangeLength = `${range}`.length;\n    let wrappedLength = 0;\n    const treeWalker = document\n      .createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);\n    let { startOffset } = range;\n    treeWalker.currentNode = range.startContainer;\n    if (treeWalker.currentNode.nodeType === Node.TEXT_NODE || treeWalker.nextNode()) {\n      do {\n        if (treeWalker.currentNode.nodeValue !== '\\n') {\n          if (treeWalker.currentNode === range.endContainer &&\n            range.endOffset < treeWalker.currentNode.nodeValue.length\n          ) {\n            treeWalker.currentNode.splitText(range.endOffset);\n          }\n          if (startOffset) {\n            treeWalker.currentNode = treeWalker.currentNode.splitText(startOffset);\n            startOffset = 0;\n          }\n          const elt = document.createElement('span');\n          Object.entries(eltProperties).forEach(([key, value]) => {\n            elt[key] = value;\n          });\n          treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode);\n          elt.appendChild(treeWalker.currentNode);\n        }\n        wrappedLength += treeWalker.currentNode.nodeValue.length;\n        if (wrappedLength >= rangeLength) {\n          break;\n        }\n      }\n      while (treeWalker.nextNode());\n    }\n  },\n  unwrapRange(eltCollection) {\n    Array.prototype.slice.call(eltCollection).forEach((elt) => {\n      // Loop in case another wrapper has been added inside\n      for (let child = elt.firstChild; child; child = elt.firstChild) {\n        if (child.nodeType === 3) {\n          if (elt.previousSibling && elt.previousSibling.nodeType === 3) {\n            child.nodeValue = elt.previousSibling.nodeValue + child.nodeValue;\n            elt.parentNode.removeChild(elt.previousSibling);\n          }\n          if (!child.nextSibling && elt.nextSibling && elt.nextSibling.nodeType === 3) {\n            child.nodeValue += elt.nextSibling.nodeValue;\n            elt.parentNode.removeChild(elt.nextSibling);\n          }\n        }\n        elt.parentNode.insertBefore(child, elt);\n      }\n      elt.parentNode.removeChild(elt);\n    });\n  },\n};\n"
  },
  {
    "path": "src/services/workspaceSvc.js",
    "content": "import store from '../store';\nimport utils from './utils';\nimport constants from '../data/constants';\nimport badgeSvc from './badgeSvc';\n\nconst forbiddenFolderNameMatcher = /^\\.stackedit-data$|^\\.stackedit-trash$|\\.md$|\\.sync$|\\.publish$/;\n\nexport default {\n\n  /**\n   * Create a file in the store with the specified fields.\n   */\n  async createFile({\n    name,\n    parentId,\n    text,\n    properties,\n    discussions,\n    comments,\n  } = {}, background = false) {\n    const id = utils.uid();\n    const item = {\n      id,\n      name: utils.sanitizeFilename(name),\n      parentId: parentId || null,\n    };\n    const content = {\n      id: `${id}/content`,\n      text: utils.sanitizeText(text || store.getters['data/computedSettings'].newFileContent),\n      properties: utils\n        .sanitizeText(properties || store.getters['data/computedSettings'].newFileProperties),\n      discussions: discussions || {},\n      comments: comments || {},\n    };\n    const workspaceUniquePaths = store.getters['workspace/currentWorkspaceHasUniquePaths'];\n\n    // Show warning dialogs\n    if (!background) {\n      // If name is being stripped\n      if (item.name !== constants.defaultName && item.name !== name) {\n        await store.dispatch('modal/open', {\n          type: 'stripName',\n          item,\n        });\n      }\n\n      // Check if there is already a file with that path\n      if (workspaceUniquePaths) {\n        const parentPath = store.getters.pathsByItemId[item.parentId] || '';\n        const path = parentPath + item.name;\n        if (store.getters.itemsByPath[path]) {\n          await store.dispatch('modal/open', {\n            type: 'pathConflict',\n            item,\n          });\n        }\n      }\n    }\n\n    // Save file and content in the store\n    store.commit('content/setItem', content);\n    store.commit('file/setItem', item);\n    if (workspaceUniquePaths) {\n      this.makePathUnique(id);\n    }\n\n    // Return the new file item\n    return store.state.file.itemsById[id];\n  },\n\n  /**\n   * Make sanity checks and then create/update the folder/file in the store.\n   */\n  async storeItem(item) {\n    const id = item.id || utils.uid();\n    const sanitizedName = utils.sanitizeFilename(item.name);\n\n    if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) {\n      await store.dispatch('modal/open', {\n        type: 'unauthorizedName',\n        item,\n      });\n      throw new Error('Unauthorized name.');\n    }\n\n    // Show warning dialogs\n    // If name has been stripped\n    if (sanitizedName !== constants.defaultName && sanitizedName !== item.name) {\n      await store.dispatch('modal/open', {\n        type: 'stripName',\n        item,\n      });\n    }\n\n    // Check if there is a path conflict\n    if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {\n      const parentPath = store.getters.pathsByItemId[item.parentId] || '';\n      const path = parentPath + sanitizedName;\n      const items = store.getters.itemsByPath[path] || [];\n      if (items.some(itemWithSamePath => itemWithSamePath.id !== id)) {\n        await store.dispatch('modal/open', {\n          type: 'pathConflict',\n          item,\n        });\n      }\n    }\n\n    return this.setOrPatchItem({\n      ...item,\n      id,\n    });\n  },\n\n  /**\n   * Create/update the folder/file in the store and make sure its path is unique.\n   */\n  setOrPatchItem(patch) {\n    const item = {\n      ...store.getters.allItemsById[patch.id] || patch,\n    };\n    if (!item.id) {\n      return null;\n    }\n\n    if (patch.parentId !== undefined) {\n      item.parentId = patch.parentId || null;\n    }\n    if (patch.name) {\n      const sanitizedName = utils.sanitizeFilename(patch.name);\n      if (item.type !== 'folder' || !forbiddenFolderNameMatcher.exec(sanitizedName)) {\n        item.name = sanitizedName;\n      }\n    }\n\n    // Save item in the store\n    store.commit(`${item.type}/setItem`, item);\n\n    // Remove circular reference\n    this.removeCircularReference(item);\n\n    // Ensure path uniqueness\n    if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {\n      this.makePathUnique(item.id);\n    }\n\n    return store.getters.allItemsById[item.id];\n  },\n\n  /**\n   * Delete a file in the store and all its related items.\n   */\n  deleteFile(fileId) {\n    // Delete the file\n    store.commit('file/deleteItem', fileId);\n    // Delete the content\n    store.commit('content/deleteItem', `${fileId}/content`);\n    // Delete the syncedContent\n    store.commit('syncedContent/deleteItem', `${fileId}/syncedContent`);\n    // Delete the contentState\n    store.commit('contentState/deleteItem', `${fileId}/contentState`);\n    // Delete sync locations\n    (store.getters['syncLocation/groupedByFileId'][fileId] || [])\n      .forEach(item => store.commit('syncLocation/deleteItem', item.id));\n    // Delete publish locations\n    (store.getters['publishLocation/groupedByFileId'][fileId] || [])\n      .forEach(item => store.commit('publishLocation/deleteItem', item.id));\n  },\n\n  /**\n   * Sanitize the whole workspace.\n   */\n  sanitizeWorkspace(idsToKeep) {\n    // Detect and remove circular references for all folders.\n    store.getters['folder/items'].forEach(folder => this.removeCircularReference(folder));\n\n    this.ensureUniquePaths(idsToKeep);\n    this.ensureUniqueLocations(idsToKeep);\n  },\n\n  /**\n   * Detect and remove circular reference for an item.\n   */\n  removeCircularReference(item) {\n    const foldersById = store.state.folder.itemsById;\n    for (\n      let parentFolder = foldersById[item.parentId];\n      parentFolder;\n      parentFolder = foldersById[parentFolder.parentId]\n    ) {\n      if (parentFolder.id === item.id) {\n        store.commit('folder/patchItem', {\n          id: item.id,\n          parentId: null,\n        });\n        break;\n      }\n    }\n  },\n\n  /**\n   * Ensure two files/folders don't have the same path if the workspace doesn't allow it.\n   */\n  ensureUniquePaths(idsToKeep = {}) {\n    if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {\n      if (Object.keys(store.getters.pathsByItemId)\n        .some(id => !idsToKeep[id] && this.makePathUnique(id))\n      ) {\n        // Just changed one item path, restart\n        this.ensureUniquePaths(idsToKeep);\n      }\n    }\n  },\n\n  /**\n   * Return false if the file/folder path is unique.\n   * Add a prefix to its name and return true otherwise.\n   */\n  makePathUnique(id) {\n    const { itemsByPath, allItemsById, pathsByItemId } = store.getters;\n    const item = allItemsById[id];\n    if (!item) {\n      return false;\n    }\n    let path = pathsByItemId[id];\n    if (itemsByPath[path].length === 1) {\n      return false;\n    }\n    const isFolder = item.type === 'folder';\n    if (isFolder) {\n      // Remove trailing slash\n      path = path.slice(0, -1);\n    }\n    for (let suffix = 1; ; suffix += 1) {\n      let pathWithSuffix = `${path}.${suffix}`;\n      if (isFolder) {\n        pathWithSuffix += '/';\n      }\n      if (!itemsByPath[pathWithSuffix]) {\n        store.commit(`${item.type}/patchItem`, {\n          id: item.id,\n          name: `${item.name}.${suffix}`,\n        });\n        return true;\n      }\n    }\n  },\n\n  addSyncLocation(location) {\n    store.commit('syncLocation/setItem', {\n      ...location,\n      id: utils.uid(),\n    });\n\n    // Sanitize the workspace\n    this.ensureUniqueLocations();\n\n    if (Object.keys(store.getters['syncLocation/currentWithWorkspaceSyncLocation']).length > 1) {\n      badgeSvc.addBadge('syncMultipleLocations');\n    }\n  },\n\n  addPublishLocation(location) {\n    store.commit('publishLocation/setItem', {\n      ...location,\n      id: utils.uid(),\n    });\n\n    // Sanitize the workspace\n    this.ensureUniqueLocations();\n\n    if (Object.keys(store.getters['publishLocation/current']).length > 1) {\n      badgeSvc.addBadge('publishMultipleLocations');\n    }\n  },\n\n  /**\n   * Ensure two sync/publish locations of the same file don't have the same hash.\n   */\n  ensureUniqueLocations(idsToKeep = {}) {\n    ['syncLocation', 'publishLocation'].forEach((type) => {\n      store.getters[`${type}/items`].forEach((item) => {\n        if (!idsToKeep[item.id]\n          && store.getters[`${type}/groupedByFileIdAndHash`][item.fileId][item.hash].length > 1\n        ) {\n          store.commit(`${item.type}/deleteItem`, item.id);\n        }\n      });\n    });\n  },\n\n  /**\n   * Drop the database and clean the localStorage for the specified workspaceId.\n   */\n  async removeWorkspace(id) {\n    // Remove from the store first as workspace tabs will reload.\n    // Workspace deletion will be persisted as soon as possible\n    // by the store.getters['data/workspaces'] watcher in localDbSvc.\n    store.dispatch('workspace/removeWorkspace', id);\n\n    // Drop the database\n    await new Promise((resolve) => {\n      const dbName = utils.getDbName(id);\n      const request = indexedDB.deleteDatabase(dbName);\n      request.onerror = resolve; // Ignore errors\n      request.onsuccess = resolve;\n    });\n\n    // Clean the local storage\n    localStorage.removeItem(`${id}/lastSyncActivity`);\n    localStorage.removeItem(`${id}/lastWindowFocus`);\n  },\n};\n"
  },
  {
    "path": "src/store/content.js",
    "content": "import DiffMatchPatch from 'diff-match-patch';\nimport moduleTemplate from './moduleTemplate';\nimport empty from '../data/empties/emptyContent';\nimport utils from '../services/utils';\nimport cledit from '../services/editor/cledit';\nimport badgeSvc from '../services/badgeSvc';\n\nconst diffMatchPatch = new DiffMatchPatch();\n\nconst module = moduleTemplate(empty);\n\nmodule.state = {\n  ...module.state,\n  revisionContent: null,\n};\n\nmodule.mutations = {\n  ...module.mutations,\n  setRevisionContent: (state, value) => {\n    if (value) {\n      state.revisionContent = {\n        ...empty(),\n        ...value,\n        id: utils.uid(),\n        hash: Date.now(),\n      };\n    } else {\n      state.revisionContent = null;\n    }\n  },\n};\n\nmodule.getters = {\n  ...module.getters,\n  current: ({ itemsById, revisionContent }, getters, rootState, rootGetters) => {\n    if (revisionContent) {\n      return revisionContent;\n    }\n    return itemsById[`${rootGetters['file/current'].id}/content`] || empty();\n  },\n  currentChangeTrigger: (state, getters) => {\n    const { current } = getters;\n    return utils.serializeObject([\n      current.id,\n      current.text,\n      current.hash,\n    ]);\n  },\n  currentProperties: (state, { current }) => utils.computeProperties(current.properties),\n  isCurrentEditable: ({ revisionContent }, { current }, rootState, rootGetters) =>\n    !revisionContent && current.id && rootGetters['layout/styles'].showEditor,\n};\n\nmodule.actions = {\n  ...module.actions,\n  patchCurrent({ state, getters, commit }, value) {\n    const { id } = getters.current;\n    if (id && !state.revisionContent) {\n      commit('patchItem', {\n        ...value,\n        id,\n      });\n    }\n  },\n  setRevisionContent({ state, rootGetters, commit }, value) {\n    const currentFile = rootGetters['file/current'];\n    const currentContent = state.itemsById[`${currentFile.id}/content`];\n    if (currentContent) {\n      const diffs = diffMatchPatch.diff_main(currentContent.text, value.text);\n      diffMatchPatch.diff_cleanupSemantic(diffs);\n      commit('setRevisionContent', {\n        text: diffs.map(([, text]) => text).join(''),\n        diffs,\n        originalText: value.text,\n      });\n    }\n  },\n  async restoreRevision({\n    state,\n    getters,\n    commit,\n    dispatch,\n  }) {\n    const { revisionContent } = state;\n    if (revisionContent) {\n      await dispatch('modal/open', 'fileRestoration', { root: true });\n      // Close revision\n      commit('setRevisionContent');\n      const currentContent = utils.deepCopy(getters.current);\n      if (currentContent) {\n        // Restore text and move discussions\n        const diffs = diffMatchPatch\n          .diff_main(currentContent.text, revisionContent.originalText);\n        diffMatchPatch.diff_cleanupSemantic(diffs);\n        Object.entries(currentContent.discussions).forEach(([, discussion]) => {\n          const adjustOffset = (offsetName) => {\n            const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end');\n            marker.adjustOffset(diffs);\n            discussion[offsetName] = marker.offset;\n          };\n          adjustOffset('start');\n          adjustOffset('end');\n        });\n        dispatch('patchCurrent', {\n          ...currentContent,\n          text: revisionContent.originalText,\n        });\n        badgeSvc.addBadge('restoreVersion');\n      }\n    }\n  },\n};\n\nexport default module;\n"
  },
  {
    "path": "src/store/contentState.js",
    "content": "import moduleTemplate from './moduleTemplate';\nimport empty from '../data/empties/emptyContentState';\n\nconst module = moduleTemplate(empty, true);\n\nmodule.getters = {\n  ...module.getters,\n  current: ({ itemsById }, getters, rootState, rootGetters) =>\n    itemsById[`${rootGetters['file/current'].id}/contentState`] || empty(),\n};\n\nmodule.actions = {\n  ...module.actions,\n  patchCurrent({ getters, commit }, value) {\n    commit('patchItem', {\n      ...value,\n      id: getters.current.id,\n    });\n  },\n};\n\nexport default module;\n"
  },
  {
    "path": "src/store/contextMenu.js",
    "content": "const setter = propertyName => (state, value) => {\n  state[propertyName] = value;\n};\n\nexport default {\n  namespaced: true,\n  state: {\n    coordinates: {\n      left: 0,\n      top: 0,\n    },\n    items: [],\n    resolve: () => {},\n  },\n  mutations: {\n    setCoordinates: setter('coordinates'),\n    setItems: setter('items'),\n    setResolve: setter('resolve'),\n  },\n  actions: {\n    open({ commit, rootState }, { coordinates, items }) {\n      commit('setItems', items);\n      // Place the context menu outside the screen\n      commit('setCoordinates', { top: 0, left: -9999 });\n      // Let the UI refresh itself\n      setTimeout(() => {\n        // Take the size of the context menu and place it\n        const elt = document.querySelector('.context-menu__inner');\n        if (elt) {\n          const height = elt.offsetHeight;\n          if (coordinates.top + height > rootState.layout.bodyHeight) {\n            coordinates.top -= height;\n          }\n          if (coordinates.top < 0) {\n            coordinates.top = 0;\n          }\n          const width = elt.offsetWidth;\n          if (coordinates.left + width > rootState.layout.bodyWidth) {\n            coordinates.left -= width;\n          }\n          if (coordinates.left < 0) {\n            coordinates.left = 0;\n          }\n          commit('setCoordinates', coordinates);\n        }\n      }, 1);\n\n      return new Promise(resolve => commit('setResolve', resolve));\n    },\n    close({ commit }) {\n      commit('setItems', []);\n      commit('setResolve', () => {});\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/data.js",
    "content": "import Vue from 'vue';\nimport yaml from 'js-yaml';\nimport utils from '../services/utils';\nimport defaultWorkspaces from '../data/defaults/defaultWorkspaces';\nimport defaultSettings from '../data/defaults/defaultSettings.yml';\nimport defaultLocalSettings from '../data/defaults/defaultLocalSettings';\nimport defaultLayoutSettings from '../data/defaults/defaultLayoutSettings';\nimport plainHtmlTemplate from '../data/templates/plainHtmlTemplate.html';\nimport styledHtmlTemplate from '../data/templates/styledHtmlTemplate.html';\nimport styledHtmlWithTocTemplate from '../data/templates/styledHtmlWithTocTemplate.html';\nimport jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html';\nimport constants from '../data/constants';\nimport features from '../data/features';\nimport badgeSvc from '../services/badgeSvc';\n\nconst itemTemplate = (id, data = {}) => ({\n  id,\n  type: 'data',\n  data,\n  hash: 0,\n});\n\nconst empty = (id) => {\n  switch (id) {\n    case 'workspaces':\n      return itemTemplate(id, defaultWorkspaces());\n    case 'settings':\n      return itemTemplate(id, '\\n');\n    case 'localSettings':\n      return itemTemplate(id, defaultLocalSettings());\n    case 'layoutSettings':\n      return itemTemplate(id, defaultLayoutSettings());\n    default:\n      return itemTemplate(id);\n  }\n};\n\n// Item IDs that will be stored in the localStorage\nconst localStorageIdSet = new Set(constants.localStorageDataIds);\n\n// Getter/setter/patcher factories\nconst getter = id => (state) => {\n  const itemsById = localStorageIdSet.has(id)\n    ? state.lsItemsById\n    : state.itemsById;\n  if (itemsById[id]) {\n    return itemsById[id].data;\n  }\n  return empty(id).data;\n};\nconst setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data));\nconst patcher = id => ({ state, commit }, data) => {\n  const itemsById = localStorageIdSet.has(id)\n    ? state.lsItemsById\n    : state.itemsById;\n  const item = Object.assign(empty(id), itemsById[id]);\n  commit('setItem', {\n    ...empty(id),\n    data: typeof data === 'object' ? {\n      ...item.data,\n      ...data,\n    } : data,\n  });\n};\n\n// For layoutSettings\nconst toggleLayoutSetting = (name, value, featureId, getters, dispatch) => {\n  const currentValue = getters.layoutSettings[name];\n  const patch = {\n    [name]: value === undefined ? !currentValue : !!value,\n  };\n  if (patch[name] !== currentValue) {\n    dispatch('patchLayoutSettings', patch);\n    badgeSvc.addBadge(featureId);\n  }\n};\n\nconst layoutSettingsToggler = (propertyName, featureId) => ({ getters, dispatch }, value) =>\n  toggleLayoutSetting(propertyName, value, featureId, getters, dispatch);\n\nconst notEnoughSpace = (layoutConstants, showGutter) =>\n  document.body.clientWidth < layoutConstants.editorMinWidth +\n    layoutConstants.explorerWidth +\n    layoutConstants.sideBarWidth +\n    layoutConstants.buttonBarWidth +\n    (showGutter ? layoutConstants.gutterWidth : 0);\n\n// For templates\nconst makeAdditionalTemplate = (name, value, helpers = '\\n') => ({\n  name,\n  value,\n  helpers,\n  isAdditional: true,\n});\nconst defaultTemplates = {\n  plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),\n  plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),\n  styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),\n  styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate),\n  jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),\n};\n\n// For tokens\nconst tokenAdder = providerId => ({ getters, dispatch }, token) => {\n  dispatch('patchTokensByType', {\n    [providerId]: {\n      ...getters[`${providerId}TokensBySub`],\n      [token.sub]: token,\n    },\n  });\n};\n\nexport default {\n  namespaced: true,\n  state: {\n    // Data items stored in the DB\n    itemsById: {},\n    // Data items stored in the localStorage\n    lsItemsById: {},\n  },\n  mutations: {\n    setItem: ({ itemsById, lsItemsById }, value) => {\n      // Create an empty item and override its data field\n      const emptyItem = empty(value.id);\n      const data = typeof value.data === 'object'\n        ? Object.assign(emptyItem.data, value.data)\n        : value.data;\n\n      // Make item with hash\n      const item = utils.addItemHash({\n        ...emptyItem,\n        data,\n      });\n\n      // Store item in itemsById or lsItemsById if its stored in the localStorage\n      Vue.set(localStorageIdSet.has(item.id) ? lsItemsById : itemsById, item.id, item);\n    },\n    deleteItem({ itemsById }, id) {\n      // Only used by localDbSvc to clean itemsById from object moved to localStorage\n      Vue.delete(itemsById, id);\n    },\n  },\n  getters: {\n    serverConf: getter('serverConf'),\n    workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById\n    settings: getter('settings'),\n    computedSettings: (state, { settings }) => {\n      const customSettings = yaml.safeLoad(settings);\n      const parsedSettings = yaml.safeLoad(defaultSettings);\n      const override = (obj, opt) => {\n        const objType = Object.prototype.toString.call(obj);\n        const optType = Object.prototype.toString.call(opt);\n        if (objType !== optType) {\n          return obj;\n        } else if (objType !== '[object Object]') {\n          return opt;\n        }\n        Object.keys(obj).forEach((key) => {\n          if (key === 'shortcuts') {\n            obj[key] = Object.assign(obj[key], opt[key]);\n          } else {\n            obj[key] = override(obj[key], opt[key]);\n          }\n        });\n        return obj;\n      };\n      return override(parsedSettings, customSettings);\n    },\n    localSettings: getter('localSettings'),\n    layoutSettings: getter('layoutSettings'),\n    templatesById: getter('templates'),\n    allTemplatesById: (state, { templatesById }) => ({\n      ...templatesById,\n      ...defaultTemplates,\n    }),\n    lastCreated: getter('lastCreated'),\n    lastOpened: getter('lastOpened'),\n    lastOpenedIds: (state, { lastOpened }, rootState) => {\n      const result = {\n        ...lastOpened,\n      };\n      const currentFileId = rootState.file.currentId;\n      if (currentFileId && !result[currentFileId]) {\n        result[currentFileId] = Date.now();\n      }\n      return Object.keys(result)\n        .filter(id => rootState.file.itemsById[id])\n        .sort((id1, id2) => result[id2] - result[id1])\n        .slice(0, 20);\n    },\n    syncDataById: getter('syncData'),\n    syncDataByItemId: (state, { syncDataById }, rootState, rootGetters) => {\n      const result = {};\n      if (rootGetters['workspace/currentWorkspaceIsGit']) {\n        Object.entries(rootGetters.gitPathsByItemId).forEach(([id, path]) => {\n          const syncDataEntry = syncDataById[path];\n          if (syncDataEntry) {\n            result[id] = syncDataEntry;\n          }\n        });\n      } else {\n        Object.entries(syncDataById).forEach(([, syncDataEntry]) => {\n          result[syncDataEntry.itemId] = syncDataEntry;\n        });\n      }\n      return result;\n    },\n    dataSyncDataById: getter('dataSyncData'),\n    tokensByType: getter('tokens'),\n    googleTokensBySub: (state, { tokensByType }) => tokensByType.google || {},\n    couchdbTokensBySub: (state, { tokensByType }) => tokensByType.couchdb || {},\n    dropboxTokensBySub: (state, { tokensByType }) => tokensByType.dropbox || {},\n    githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {},\n    gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {},\n    wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {},\n    zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {},\n    badgeCreations: getter('badgeCreations'),\n    badgeTree: (state, { badgeCreations }) => features\n      .map(feature => feature.toBadge(badgeCreations)),\n    allBadges: (state, { badgeTree }) => {\n      const result = [];\n      const processBadgeNodes = nodes => nodes.forEach((node) => {\n        result.push(node);\n        if (node.children) {\n          processBadgeNodes(node.children);\n        }\n      });\n      processBadgeNodes(badgeTree);\n      return result;\n    },\n  },\n  actions: {\n    setServerConf: setter('serverConf'),\n    setSettings: setter('settings'),\n    patchLocalSettings: patcher('localSettings'),\n    patchLayoutSettings: patcher('layoutSettings'),\n    toggleNavigationBar: layoutSettingsToggler('showNavigationBar', 'toggleNavigationBar'),\n    toggleEditor: layoutSettingsToggler('showEditor', 'toggleEditor'),\n    toggleSidePreview: layoutSettingsToggler('showSidePreview', 'toggleSidePreview'),\n    toggleStatusBar: layoutSettingsToggler('showStatusBar', 'toggleStatusBar'),\n    toggleScrollSync: layoutSettingsToggler('scrollSync', 'toggleScrollSync'),\n    toggleFocusMode: layoutSettingsToggler('focusMode', 'toggleFocusMode'),\n    toggleSideBar: ({ getters, dispatch, rootGetters }, value) => {\n      // Reset side bar\n      dispatch('setSideBarPanel');\n\n      // Toggle it\n      toggleLayoutSetting('showSideBar', value, 'toggleSideBar', getters, dispatch);\n\n      // Close explorer if not enough space\n      if (getters.layoutSettings.showSideBar &&\n        notEnoughSpace(rootGetters['layout/constants'], rootGetters['discussion/currentDiscussion'])\n      ) {\n        dispatch('patchLayoutSettings', {\n          showExplorer: false,\n        });\n      }\n    },\n    toggleExplorer: ({ getters, dispatch, rootGetters }, value) => {\n      // Toggle explorer\n      toggleLayoutSetting('showExplorer', value, 'toggleExplorer', getters, dispatch);\n\n      // Close side bar if not enough space\n      if (getters.layoutSettings.showExplorer &&\n        notEnoughSpace(rootGetters['layout/constants'], rootGetters['discussion/currentDiscussion'])\n      ) {\n        dispatch('patchLayoutSettings', {\n          showSideBar: false,\n        });\n      }\n    },\n    setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', {\n      sideBarPanel: value === undefined ? 'menu' : value,\n    }),\n    setTemplatesById: ({ commit }, templatesById) => {\n      const templatesToCommit = {\n        ...templatesById,\n      };\n      // We don't store additional templates\n      Object.keys(defaultTemplates).forEach((id) => {\n        delete templatesToCommit[id];\n      });\n      commit('setItem', itemTemplate('templates', templatesToCommit));\n    },\n    setLastCreated: setter('lastCreated'),\n    setLastOpenedId: ({ getters, commit, rootState }, fileId) => {\n      const lastOpened = { ...getters.lastOpened };\n      lastOpened[fileId] = Date.now();\n      // Remove entries that don't exist anymore\n      const cleanedLastOpened = {};\n      Object.entries(lastOpened).forEach(([id, value]) => {\n        if (rootState.file.itemsById[id]) {\n          cleanedLastOpened[id] = value;\n        }\n      });\n      commit('setItem', itemTemplate('lastOpened', cleanedLastOpened));\n    },\n    setSyncDataById: setter('syncData'),\n    patchSyncDataById: patcher('syncData'),\n    patchDataSyncDataById: patcher('dataSyncData'),\n    patchTokensByType: patcher('tokens'),\n    addGoogleToken: tokenAdder('google'),\n    addCouchdbToken: tokenAdder('couchdb'),\n    addDropboxToken: tokenAdder('dropbox'),\n    addGithubToken: tokenAdder('github'),\n    addGitlabToken: tokenAdder('gitlab'),\n    addWordpressToken: tokenAdder('wordpress'),\n    addZendeskToken: tokenAdder('zendesk'),\n    patchBadgeCreations: patcher('badgeCreations'),\n  },\n};\n"
  },
  {
    "path": "src/store/discussion.js",
    "content": "import utils from '../services/utils';\nimport googleHelper from '../services/providers/helpers/googleHelper';\nimport syncSvc from '../services/syncSvc';\n\nconst idShifter = offset => (state, getters) => {\n  const ids = Object.keys(getters.currentFileDiscussions)\n    .filter(id => id !== state.newDiscussionId);\n  const idx = ids.indexOf(state.currentDiscussionId) + offset + ids.length;\n  return ids[idx % ids.length];\n};\n\nexport default {\n  namespaced: true,\n  state: {\n    currentDiscussionId: null,\n    newDiscussion: null,\n    newDiscussionId: null,\n    isCommenting: false,\n    newCommentText: '',\n    newCommentSelection: { start: 0, end: 0 },\n    newCommentFocus: false,\n    stickyComment: null,\n  },\n  mutations: {\n    setCurrentDiscussionId: (state, value) => {\n      if (state.currentDiscussionId !== value) {\n        state.currentDiscussionId = value;\n        state.isCommenting = false;\n      }\n    },\n    setNewDiscussion: (state, value) => {\n      state.newDiscussion = value;\n      state.newDiscussionId = utils.uid();\n      state.currentDiscussionId = state.newDiscussionId;\n      state.isCommenting = true;\n      state.newCommentFocus = true;\n    },\n    patchNewDiscussion: (state, value) => {\n      Object.assign(state.newDiscussion, value);\n    },\n    setIsCommenting: (state, value) => {\n      state.isCommenting = value;\n      if (!value) {\n        state.newDiscussionId = null;\n      } else {\n        state.newCommentFocus = true;\n      }\n    },\n    setNewCommentText: (state, value) => {\n      state.newCommentText = value || '';\n    },\n    setNewCommentSelection: (state, value) => {\n      state.newCommentSelection = value;\n    },\n    setNewCommentFocus: (state, value) => {\n      state.newCommentFocus = value;\n    },\n    setStickyComment: (state, value) => {\n      state.stickyComment = value;\n    },\n  },\n  getters: {\n    newDiscussion: ({ currentDiscussionId, newDiscussionId, newDiscussion }) =>\n      currentDiscussionId === newDiscussionId && newDiscussion,\n    currentFileDiscussionLastComments: (state, getters, rootState, rootGetters) => {\n      const { discussions, comments } = rootGetters['content/current'];\n      const discussionLastComments = {};\n      Object.entries(comments).forEach(([, comment]) => {\n        if (discussions[comment.discussionId]) {\n          const lastComment = discussionLastComments[comment.discussionId];\n          if (!lastComment || lastComment.created < comment.created) {\n            discussionLastComments[comment.discussionId] = comment;\n          }\n        }\n      });\n      return discussionLastComments;\n    },\n    currentFileDiscussions: (\n      { newDiscussionId },\n      { newDiscussion, currentFileDiscussionLastComments },\n      rootState,\n      rootGetters,\n    ) => {\n      const currentFileDiscussions = {};\n      if (newDiscussion) {\n        currentFileDiscussions[newDiscussionId] = newDiscussion;\n      }\n      const { discussions } = rootGetters['content/current'];\n      Object.entries(currentFileDiscussionLastComments)\n        .sort(([, lastComment1], [, lastComment2]) =>\n          lastComment1.created - lastComment2.created)\n        .forEach(([discussionId]) => {\n          currentFileDiscussions[discussionId] = discussions[discussionId];\n        });\n      return currentFileDiscussions;\n    },\n    currentDiscussion: ({ currentDiscussionId }, { currentFileDiscussions }) =>\n      currentFileDiscussions[currentDiscussionId],\n    previousDiscussionId: idShifter(-1),\n    nextDiscussionId: idShifter(1),\n    currentDiscussionComments: (\n      { currentDiscussionId },\n      { currentDiscussion },\n      rootState,\n      rootGetters,\n    ) => {\n      const comments = {};\n      if (currentDiscussion) {\n        const contentComments = rootGetters['content/current'].comments;\n        Object.entries(contentComments)\n          .filter(([, comment]) =>\n            comment.discussionId === currentDiscussionId)\n          .sort(([, comment1], [, comment2]) =>\n            comment1.created - comment2.created)\n          .forEach(([commentId, comment]) => {\n            comments[commentId] = comment;\n          });\n      }\n      return comments;\n    },\n    currentDiscussionLastCommentId: (state, { currentDiscussionComments }) =>\n      Object.keys(currentDiscussionComments).pop(),\n    currentDiscussionLastComment: (\n      state,\n      { currentDiscussionComments, currentDiscussionLastCommentId },\n    ) => currentDiscussionComments[currentDiscussionLastCommentId],\n  },\n  actions: {\n    cancelNewComment({ commit, getters }) {\n      commit('setIsCommenting', false);\n      if (!getters.currentDiscussion) {\n        commit('setCurrentDiscussionId', getters.nextDiscussionId);\n      }\n    },\n    async createNewDiscussion({ commit, dispatch, rootGetters }, selection) {\n      const loginToken = rootGetters['workspace/loginToken'];\n      if (!loginToken) {\n        try {\n          await dispatch('modal/open', 'signInForComment', { root: true });\n          await googleHelper.signin();\n          syncSvc.requestSync();\n          await dispatch('createNewDiscussion', selection);\n        } catch (e) { /* cancel */ }\n      } else if (selection) {\n        let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim();\n        const maxLength = 80;\n        if (text.length > maxLength) {\n          text = `${text.slice(0, maxLength - 1).trim()}…`;\n        }\n        commit('setNewDiscussion', { ...selection, text });\n      }\n    },\n    cleanCurrentFile({\n      getters,\n      rootGetters,\n      commit,\n      dispatch,\n    }, { filterComment, filterDiscussion } = {}) {\n      const { discussions } = rootGetters['content/current'];\n      const { comments } = rootGetters['content/current'];\n      const patch = {\n        discussions: {},\n        comments: {},\n      };\n      Object.entries(comments).forEach(([commentId, comment]) => {\n        const discussion = discussions[comment.discussionId];\n        if (discussion && comment !== filterComment && discussion !== filterDiscussion) {\n          patch.discussions[comment.discussionId] = discussion;\n          patch.comments[commentId] = comment;\n        }\n      });\n\n      const { nextDiscussionId } = getters;\n      dispatch('content/patchCurrent', patch, { root: true });\n      if (!getters.currentDiscussion) {\n        // Keep the gutter open\n        commit('setCurrentDiscussionId', nextDiscussionId);\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/explorer.js",
    "content": "import Vue from 'vue';\nimport emptyFile from '../data/empties/emptyFile';\nimport emptyFolder from '../data/empties/emptyFolder';\n\nconst setter = propertyName => (state, value) => {\n  state[propertyName] = value;\n};\n\nfunction debounceAction(action, wait) {\n  let timeoutId;\n  return (context) => {\n    clearTimeout(timeoutId);\n    timeoutId = setTimeout(() => action(context), wait);\n  };\n}\n\nconst collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });\nconst compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name);\n\nclass Node {\n  constructor(item, locations = [], isFolder = false) {\n    this.item = item;\n    this.locations = locations;\n    this.isFolder = isFolder;\n    if (isFolder) {\n      this.folders = [];\n      this.files = [];\n    }\n  }\n\n  sortChildren() {\n    if (this.isFolder) {\n      this.folders.sort(compare);\n      this.files.sort(compare);\n      this.folders.forEach(child => child.sortChildren());\n    }\n  }\n}\n\nconst nilFileNode = new Node(emptyFile());\nnilFileNode.isNil = true;\nconst fakeFileNode = new Node(emptyFile());\nfakeFileNode.item.id = 'fake';\nfakeFileNode.noDrag = true;\n\nfunction getParent({ item, isNil }, { nodeMap, rootNode }) {\n  if (isNil) {\n    return nilFileNode;\n  }\n  return nodeMap[item.parentId] || rootNode;\n}\n\nfunction getFolder(node, getters) {\n  return node.item.type === 'folder' ?\n    node :\n    getParent(node, getters);\n}\n\nexport default {\n  namespaced: true,\n  state: {\n    selectedId: null,\n    editingId: null,\n    dragSourceId: null,\n    dragTargetId: null,\n    newChildNode: nilFileNode,\n    openNodes: {},\n  },\n  mutations: {\n    setSelectedId: setter('selectedId'),\n    setEditingId: setter('editingId'),\n    setDragSourceId: setter('dragSourceId'),\n    setDragTargetId: setter('dragTargetId'),\n    setNewItem(state, item) {\n      state.newChildNode = item ? new Node(item, [], item.type === 'folder') : nilFileNode;\n    },\n    setNewItemName(state, name) {\n      state.newChildNode.item.name = name;\n    },\n    toggleOpenNode(state, id) {\n      Vue.set(state.openNodes, id, !state.openNodes[id]);\n    },\n  },\n  getters: {\n    nodeStructure: (state, getters, rootState, rootGetters) => {\n      const rootNode = new Node(emptyFolder(), [], true);\n      rootNode.isRoot = true;\n\n      // Create Trash node\n      const trashFolderNode = new Node(emptyFolder(), [], true);\n      trashFolderNode.item.id = 'trash';\n      trashFolderNode.item.name = 'Trash';\n      trashFolderNode.noDrag = true;\n      trashFolderNode.isTrash = true;\n      trashFolderNode.parentNode = rootNode;\n\n      // Create Temp node\n      const tempFolderNode = new Node(emptyFolder(), [], true);\n      tempFolderNode.item.id = 'temp';\n      tempFolderNode.item.name = 'Temp';\n      tempFolderNode.noDrag = true;\n      tempFolderNode.noDrop = true;\n      tempFolderNode.isTemp = true;\n      tempFolderNode.parentNode = rootNode;\n\n      // Fill nodeMap with all file and folder nodes\n      const nodeMap = {\n        trash: trashFolderNode,\n        temp: tempFolderNode,\n      };\n      rootGetters['folder/items'].forEach((item) => {\n        nodeMap[item.id] = new Node(item, [], true);\n      });\n      const syncLocationsByFileId = rootGetters['syncLocation/filteredGroupedByFileId'];\n      const publishLocationsByFileId = rootGetters['publishLocation/filteredGroupedByFileId'];\n      rootGetters['file/items'].forEach((item) => {\n        const locations = [\n          ...syncLocationsByFileId[item.id] || [],\n          ...publishLocationsByFileId[item.id] || [],\n        ];\n        nodeMap[item.id] = new Node(item, locations);\n      });\n\n      // Build the tree\n      Object.entries(nodeMap).forEach(([, node]) => {\n        let parentNode = nodeMap[node.item.parentId];\n        if (!parentNode || !parentNode.isFolder) {\n          if (node.isTrash || node.isTemp) {\n            return;\n          }\n          parentNode = rootNode;\n        }\n        if (node.isFolder) {\n          parentNode.folders.push(node);\n        } else {\n          parentNode.files.push(node);\n        }\n        node.parentNode = parentNode;\n      });\n      rootNode.sortChildren();\n\n      // Add Trash and Temp nodes\n      rootNode.folders.unshift(tempFolderNode);\n      tempFolderNode.files.forEach((node) => {\n        node.noDrop = true;\n      });\n      rootNode.folders.unshift(trashFolderNode);\n\n      // Add a fake file at the end of the root folder to allow drag and drop into it\n      rootNode.files.push(fakeFileNode);\n      return {\n        nodeMap,\n        rootNode,\n      };\n    },\n    nodeMap: (state, { nodeStructure }) => nodeStructure.nodeMap,\n    rootNode: (state, { nodeStructure }) => nodeStructure.rootNode,\n    newChildNodeParent: (state, getters) => getParent(state.newChildNode, getters),\n    selectedNode: ({ selectedId }, { nodeMap }) => nodeMap[selectedId] || nilFileNode,\n    selectedNodeFolder: (state, getters) => getFolder(getters.selectedNode, getters),\n    editingNode: ({ editingId }, { nodeMap }) => nodeMap[editingId] || nilFileNode,\n    dragSourceNode: ({ dragSourceId }, { nodeMap }) => nodeMap[dragSourceId] || nilFileNode,\n    dragTargetNode: ({ dragTargetId }, { nodeMap }) => {\n      if (dragTargetId === 'fake') {\n        return fakeFileNode;\n      }\n      return nodeMap[dragTargetId] || nilFileNode;\n    },\n    dragTargetNodeFolder: ({ dragTargetId }, getters) => {\n      if (dragTargetId === 'fake') {\n        return getters.rootNode;\n      }\n      return getFolder(getters.dragTargetNode, getters);\n    },\n  },\n  actions: {\n    openNode({\n      state,\n      getters,\n      commit,\n      dispatch,\n    }, id) {\n      const node = getters.nodeMap[id];\n      if (node) {\n        if (node.isFolder && !state.openNodes[id]) {\n          commit('toggleOpenNode', id);\n        }\n        dispatch('openNode', node.item.parentId);\n      }\n    },\n    openDragTarget: debounceAction(({ state, dispatch }) => {\n      dispatch('openNode', state.dragTargetId);\n    }, 1000),\n    setDragTarget({ commit, getters, dispatch }, node) {\n      if (!node) {\n        commit('setDragTargetId');\n      } else {\n        // Make sure target node is not a child of source node\n        const folderNode = getFolder(node, getters);\n        const sourceId = getters.dragSourceNode.item.id;\n        const { nodeMap } = getters;\n        for (let parentNode = folderNode;\n          parentNode;\n          parentNode = nodeMap[parentNode.item.parentId]\n        ) {\n          if (parentNode.item.id === sourceId) {\n            commit('setDragTargetId');\n            return;\n          }\n        }\n\n        commit('setDragTargetId', node.item.id);\n        dispatch('openDragTarget');\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/file.js",
    "content": "import moduleTemplate from './moduleTemplate';\nimport empty from '../data/empties/emptyFile';\n\nconst module = moduleTemplate(empty);\n\nmodule.state = {\n  ...module.state,\n  currentId: null,\n};\n\nmodule.getters = {\n  ...module.getters,\n  current: ({ itemsById, currentId }) => itemsById[currentId] || empty(),\n  isCurrentTemp: (state, { current }) => current.parentId === 'temp',\n  lastOpened: ({ itemsById }, { items }, rootState, rootGetters) =>\n    itemsById[rootGetters['data/lastOpenedIds'][0]] || items[0] || empty(),\n};\n\nmodule.mutations = {\n  ...module.mutations,\n  setCurrentId(state, value) {\n    state.currentId = value;\n  },\n};\n\nmodule.actions = {\n  ...module.actions,\n  patchCurrent({ getters, commit }, value) {\n    commit('patchItem', {\n      ...value,\n      id: getters.current.id,\n    });\n  },\n};\n\nexport default module;\n"
  },
  {
    "path": "src/store/findReplace.js",
    "content": "export default {\n  namespaced: true,\n  state: {\n    type: null,\n    lastOpen: 0,\n    findText: '',\n    replaceText: '',\n  },\n  mutations: {\n    setType: (state, value) => {\n      state.type = value;\n    },\n    setLastOpen: (state) => {\n      state.lastOpen = Date.now();\n    },\n    setFindText: (state, value) => {\n      state.findText = value;\n    },\n    setReplaceText: (state, value) => {\n      state.replaceText = value;\n    },\n  },\n  actions: {\n    open({ commit }, { type, findText }) {\n      commit('setType', type);\n      if (findText) {\n        commit('setFindText', findText);\n      }\n      commit('setLastOpen');\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/folder.js",
    "content": "import moduleTemplate from './moduleTemplate';\nimport empty from '../data/empties/emptyFolder';\n\nconst module = moduleTemplate(empty);\n\nexport default module;\n"
  },
  {
    "path": "src/store/index.js",
    "content": "import createLogger from 'vuex/dist/logger';\nimport Vue from 'vue';\nimport Vuex from 'vuex';\nimport utils from '../services/utils';\nimport content from './content';\nimport contentState from './contentState';\nimport contextMenu from './contextMenu';\nimport data from './data';\nimport discussion from './discussion';\nimport explorer from './explorer';\nimport file from './file';\nimport findReplace from './findReplace';\nimport folder from './folder';\nimport layout from './layout';\nimport modal from './modal';\nimport notification from './notification';\nimport queue from './queue';\nimport syncedContent from './syncedContent';\nimport userInfo from './userInfo';\nimport workspace from './workspace';\nimport locationTemplate from './locationTemplate';\nimport emptyPublishLocation from '../data/empties/emptyPublishLocation';\nimport emptySyncLocation from '../data/empties/emptySyncLocation';\nimport constants from '../data/constants';\n\nVue.use(Vuex);\n\nconst debug = NODE_ENV !== 'production';\n\nconst store = new Vuex.Store({\n  modules: {\n    content,\n    contentState,\n    contextMenu,\n    data,\n    discussion,\n    explorer,\n    file,\n    findReplace,\n    folder,\n    layout,\n    modal,\n    notification,\n    publishLocation: locationTemplate(emptyPublishLocation),\n    queue,\n    syncedContent,\n    syncLocation: locationTemplate(emptySyncLocation),\n    userInfo,\n    workspace,\n  },\n  state: {\n    light: false,\n    offline: false,\n    lastOfflineCheck: 0,\n    timeCounter: 0,\n  },\n  mutations: {\n    setLight: (state, value) => {\n      state.light = value;\n    },\n    setOffline: (state, value) => {\n      state.offline = value;\n    },\n    updateLastOfflineCheck: (state) => {\n      state.lastOfflineCheck = Date.now();\n    },\n    updateTimeCounter: (state) => {\n      state.timeCounter += 1;\n    },\n  },\n  getters: {\n    allItemsById: (state) => {\n      const result = {};\n      constants.types.forEach(type => Object.assign(result, state[type].itemsById));\n      return result;\n    },\n    pathsByItemId: (state, getters) => {\n      const result = {};\n      const processNode = (node, parentPath = '') => {\n        let path = parentPath;\n        if (node.item.id) {\n          path += node.item.name;\n          if (node.isTrash) {\n            path = '.stackedit-trash/';\n          } else if (node.isFolder) {\n            path += '/';\n          }\n          result[node.item.id] = path;\n        }\n\n        if (node.isFolder) {\n          node.folders.forEach(child => processNode(child, path));\n          node.files.forEach(child => processNode(child, path));\n        }\n      };\n\n      processNode(getters['explorer/rootNode']);\n      return result;\n    },\n    itemsByPath: (state, { allItemsById, pathsByItemId }) => {\n      const result = {};\n      Object.entries(pathsByItemId).forEach(([id, path]) => {\n        const items = result[path] || [];\n        items.push(allItemsById[id]);\n        result[path] = items;\n      });\n      return result;\n    },\n    gitPathsByItemId: (state, { allItemsById, pathsByItemId }) => {\n      const result = {};\n      Object.entries(allItemsById).forEach(([id, item]) => {\n        if (item.type === 'data') {\n          result[id] = `.stackedit-data/${id}.json`;\n        } else if (item.type === 'file') {\n          const filePath = pathsByItemId[id];\n          result[id] = `${filePath}.md`;\n          result[`${id}/content`] = `/${filePath}.md`;\n        } else if (item.type === 'content') {\n          const [fileId] = id.split('/');\n          const filePath = pathsByItemId[fileId];\n          result[fileId] = `${filePath}.md`;\n          result[id] = `/${filePath}.md`;\n        } else if (item.type === 'folder') {\n          result[id] = pathsByItemId[id];\n        } else if (item.type === 'syncLocation' || item.type === 'publishLocation') {\n          // locations are stored as paths\n          const encodedItem = utils.encodeBase64(utils.serializeObject({\n            ...item,\n            id: undefined,\n            type: undefined,\n            fileId: undefined,\n            hash: undefined,\n          }), true);\n          const extension = item.type === 'syncLocation' ? 'sync' : 'publish';\n          result[id] = `${pathsByItemId[item.fileId]}.${encodedItem}.${extension}`;\n        }\n      });\n      return result;\n    },\n    itemIdsByGitPath: (state, { gitPathsByItemId }) => {\n      const result = {};\n      Object.entries(gitPathsByItemId).forEach(([id, path]) => {\n        result[path] = id;\n      });\n      return result;\n    },\n    itemsByGitPath: (state, { allItemsById, gitPathsByItemId }) => {\n      const result = {};\n      Object.entries(gitPathsByItemId).forEach(([id, path]) => {\n        const item = allItemsById[id];\n        if (item) {\n          result[path] = item;\n        }\n      });\n      return result;\n    },\n    isSponsor: ({ light }, getters) => {\n      if (light) {\n        return true;\n      }\n      if (!getters['data/serverConf'].allowSponsorship) {\n        return true;\n      }\n      const sponsorToken = getters['workspace/sponsorToken'];\n      return sponsorToken ? sponsorToken.isSponsor : false;\n    },\n  },\n  actions: {\n    setOffline: ({ state, commit, dispatch }, value) => {\n      if (state.offline !== value) {\n        commit('setOffline', value);\n        if (state.offline) {\n          return Promise.reject(new Error('You are offline.'));\n        }\n        dispatch('notification/info', 'You are back online!');\n      }\n      return Promise.resolve();\n    },\n  },\n  strict: debug,\n  plugins: debug ? [createLogger()] : [],\n});\n\nsetInterval(() => {\n  store.commit('updateTimeCounter');\n}, 30 * 1000);\n\nexport default store;\n"
  },
  {
    "path": "src/store/layout.js",
    "content": "import pagedownButtons from '../data/pagedownButtons';\n\nlet buttonCount = 2; // 2 for undo/redo\nlet spacerCount = 0;\npagedownButtons.forEach((button) => {\n  if (button.method) {\n    buttonCount += 1;\n  } else {\n    spacerCount += 1;\n  }\n});\n\nconst minPadding = 25;\nconst editorTopPadding = 10;\nconst navigationBarEditButtonsWidth = (34 * buttonCount) + (8 * spacerCount); // buttons + spacers\nconst navigationBarLeftButtonWidth = 38 + 4 + 12;\nconst navigationBarRightButtonWidth = 38 + 8;\nconst navigationBarSpinnerWidth = 24 + 8 + 5; // 5 for left margin\nconst navigationBarLocationWidth = 20;\nconst navigationBarSyncPublishButtonsWidth = 34 + 10;\nconst navigationBarTitleMargin = 8;\nconst maxTitleMaxWidth = 800;\nconst minTitleMaxWidth = 200;\n\nconst constants = {\n  editorMinWidth: 320,\n  explorerWidth: 260,\n  gutterWidth: 250,\n  sideBarWidth: 280,\n  navigationBarHeight: 44,\n  buttonBarWidth: 26,\n  statusBarHeight: 20,\n};\n\nfunction computeStyles(state, getters, layoutSettings = getters['data/layoutSettings'], styles = {\n  showNavigationBar: layoutSettings.showNavigationBar\n    || !layoutSettings.showEditor\n    || state.content.revisionContent\n    || state.light,\n  showStatusBar: layoutSettings.showStatusBar,\n  showEditor: layoutSettings.showEditor,\n  showSidePreview: layoutSettings.showSidePreview && layoutSettings.showEditor,\n  showPreview: layoutSettings.showSidePreview || !layoutSettings.showEditor,\n  showSideBar: layoutSettings.showSideBar && !state.light,\n  showExplorer: layoutSettings.showExplorer && !state.light,\n  layoutOverflow: false,\n  hideLocations: state.light,\n}) {\n  styles.innerHeight = state.layout.bodyHeight;\n  if (styles.showNavigationBar) {\n    styles.innerHeight -= constants.navigationBarHeight;\n  }\n  if (styles.showStatusBar) {\n    styles.innerHeight -= constants.statusBarHeight;\n  }\n\n  styles.innerWidth = state.layout.bodyWidth;\n  if (styles.innerWidth < constants.editorMinWidth\n    + constants.gutterWidth + constants.buttonBarWidth\n  ) {\n    styles.layoutOverflow = true;\n  }\n  if (styles.showSideBar) {\n    styles.innerWidth -= constants.sideBarWidth;\n  }\n  if (styles.showExplorer) {\n    styles.innerWidth -= constants.explorerWidth;\n  }\n\n  let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth;\n  // No commenting for temp files\n  const showGutter = !getters['file/isCurrentTemp'] && !!getters['discussion/currentDiscussion'];\n  if (showGutter) {\n    doublePanelWidth -= constants.gutterWidth;\n  }\n  if (doublePanelWidth < constants.editorMinWidth) {\n    doublePanelWidth = constants.editorMinWidth;\n  }\n\n  if (styles.showSidePreview && doublePanelWidth / 2 < constants.editorMinWidth) {\n    styles.showSidePreview = false;\n    styles.showPreview = false;\n    styles.layoutOverflow = false;\n    return computeStyles(state, getters, layoutSettings, styles);\n  }\n\n  const computedSettings = getters['data/computedSettings'];\n  styles.fontSize = 18;\n  styles.textWidth = 990;\n  if (doublePanelWidth < 1120) {\n    styles.fontSize -= 1;\n    styles.textWidth = 910;\n  }\n  if (doublePanelWidth < 1040) {\n    styles.textWidth = 830;\n  }\n  styles.textWidth *= computedSettings.maxWidthFactor;\n  if (doublePanelWidth < styles.textWidth) {\n    styles.textWidth = doublePanelWidth;\n  }\n  if (styles.textWidth < 640) {\n    styles.fontSize -= 1;\n  }\n  styles.fontSize *= computedSettings.fontSizeFactor;\n\n  const bottomPadding = Math.floor(styles.innerHeight / 2);\n  const panelWidth = Math.floor(doublePanelWidth / 2);\n  styles.previewWidth = styles.showSidePreview ?\n    panelWidth :\n    doublePanelWidth;\n  const previewRightPadding = Math\n    .max(Math.floor((styles.previewWidth - styles.textWidth) / 2), minPadding);\n  if (!styles.showSidePreview) {\n    styles.previewWidth += constants.buttonBarWidth;\n  }\n  styles.previewGutterWidth = showGutter && !layoutSettings.showEditor\n    ? constants.gutterWidth\n    : 0;\n  const previewLeftPadding = previewRightPadding + styles.previewGutterWidth;\n  styles.previewGutterLeft = previewLeftPadding - minPadding;\n  styles.previewPadding = `${editorTopPadding}px ${previewRightPadding}px ${bottomPadding}px ${previewLeftPadding}px`;\n  styles.editorWidth = styles.showSidePreview ?\n    panelWidth :\n    doublePanelWidth;\n  const editorRightPadding = Math\n    .max(Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding);\n  styles.editorGutterWidth = showGutter && layoutSettings.showEditor\n    ? constants.gutterWidth\n    : 0;\n  const editorLeftPadding = editorRightPadding + styles.editorGutterWidth;\n  styles.editorGutterLeft = editorLeftPadding - minPadding;\n  styles.editorPadding = `${editorTopPadding}px ${editorRightPadding}px ${bottomPadding}px ${editorLeftPadding}px`;\n\n  styles.titleMaxWidth = styles.innerWidth -\n    navigationBarLeftButtonWidth -\n    navigationBarRightButtonWidth -\n    navigationBarSpinnerWidth;\n  if (styles.showEditor) {\n    const syncLocations = getters['syncLocation/current'];\n    const publishLocations = getters['publishLocation/current'];\n    styles.titleMaxWidth -= navigationBarEditButtonsWidth +\n      (navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) +\n      (navigationBarSyncPublishButtonsWidth * 2) +\n      navigationBarTitleMargin;\n    if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) {\n      styles.hideLocations = true;\n    }\n  }\n  styles.titleMaxWidth = Math\n    .max(minTitleMaxWidth, Math\n      .min(maxTitleMaxWidth, styles.titleMaxWidth));\n  return styles;\n}\n\nexport default {\n  namespaced: true,\n  state: {\n    canUndo: false,\n    canRedo: false,\n    bodyWidth: 0,\n    bodyHeight: 0,\n  },\n  mutations: {\n    setCanUndo: (state, value) => {\n      state.canUndo = value;\n    },\n    setCanRedo: (state, value) => {\n      state.canRedo = value;\n    },\n    updateBodySize: (state) => {\n      state.bodyWidth = document.body.clientWidth;\n      state.bodyHeight = document.body.clientHeight;\n    },\n  },\n  getters: {\n    constants: () => constants,\n    styles: (state, getters, rootState, rootGetters) => computeStyles(rootState, rootGetters),\n  },\n  actions: {\n    updateBodySize({ commit, dispatch, rootGetters }) {\n      commit('updateBodySize');\n      // Make sure both explorer and side bar are not open if body width is small\n      const layoutSettings = rootGetters['data/layoutSettings'];\n      dispatch('data/toggleExplorer', layoutSettings.showExplorer, { root: true });\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/locationTemplate.js",
    "content": "import moduleTemplate from './moduleTemplate';\nimport providerRegistry from '../services/providers/common/providerRegistry';\nimport utils from '../services/utils';\n\nconst addToGroup = (groups, item) => {\n  const list = groups[item.fileId];\n  if (!list) {\n    groups[item.fileId] = [item];\n  } else {\n    list.push(item);\n  }\n};\n\nexport default (empty) => {\n  const module = moduleTemplate(empty);\n\n  module.getters = {\n    ...module.getters,\n    groupedByFileId: (state, { items }) => {\n      const groups = {};\n      items.forEach(item => addToGroup(groups, item));\n      return groups;\n    },\n    groupedByFileIdAndHash: (state, { items }) => {\n      const fileIdGroups = {};\n      items.forEach((item) => {\n        let hashGroups = fileIdGroups[item.fileId];\n        if (!hashGroups) {\n          hashGroups = {};\n          fileIdGroups[item.fileId] = hashGroups;\n        }\n        const list = hashGroups[item.hash];\n        if (!list) {\n          hashGroups[item.hash] = [item];\n        } else {\n          list.push(item);\n        }\n      });\n      return fileIdGroups;\n    },\n    filteredGroupedByFileId: (state, { items }) => {\n      const groups = {};\n      items\n        .filter((item) => {\n          // Filter items that we can't use\n          const provider = providerRegistry.providersById[item.providerId];\n          return provider && provider.getToken(item);\n        })\n        .forEach(item => addToGroup(groups, item));\n      return groups;\n    },\n    current: (state, { filteredGroupedByFileId }, rootState, rootGetters) => {\n      const locations = filteredGroupedByFileId[rootGetters['file/current'].id] || [];\n      return locations.map((location) => {\n        const provider = providerRegistry.providersById[location.providerId];\n        return {\n          ...location,\n          description: utils.sanitizeName(provider.getLocationDescription(location)),\n          url: provider.getLocationUrl(location),\n        };\n      });\n    },\n    currentWithWorkspaceSyncLocation: (state, { current }, rootState, rootGetters) => {\n      const fileId = rootGetters['file/current'].id;\n      const fileSyncData = rootGetters['data/syncDataByItemId'][fileId];\n      const contentSyncData = rootGetters['data/syncDataByItemId'][`${fileId}/content`];\n      if (!fileSyncData || !contentSyncData) {\n        return current;\n      }\n\n      // Add the workspace sync location\n      const workspaceProvider = providerRegistry.providersById[\n        rootGetters['workspace/currentWorkspace'].providerId];\n      return [{\n        id: 'main',\n        providerId: workspaceProvider.id,\n        fileId,\n        description: utils.sanitizeName(workspaceProvider\n          .getSyncDataDescription(fileSyncData, contentSyncData)),\n        url: workspaceProvider.getSyncDataUrl(fileSyncData, contentSyncData),\n      }, ...current];\n    },\n  };\n\n  return module;\n};\n"
  },
  {
    "path": "src/store/modal.js",
    "content": "export default {\n  namespaced: true,\n  state: {\n    stack: [],\n    hidden: false,\n  },\n  mutations: {\n    setStack: (state, value) => {\n      state.stack = value;\n    },\n    setHidden: (state, value) => {\n      state.hidden = value;\n    },\n  },\n  getters: {\n    config: ({ hidden, stack }) => !hidden && stack[0],\n  },\n  actions: {\n    async open({ commit, state }, param) {\n      const config = typeof param === 'object' ? { ...param } : { type: param };\n      try {\n        return await new Promise((resolve, reject) => {\n          config.resolve = resolve;\n          config.reject = reject;\n          commit('setStack', [config, ...state.stack]);\n        });\n      } finally {\n        commit('setStack', state.stack.filter((otherConfig => otherConfig !== config)));\n      }\n    },\n    async hideUntil({ commit }, promise) {\n      try {\n        commit('setHidden', true);\n        return await promise;\n      } finally {\n        commit('setHidden', false);\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/moduleTemplate.js",
    "content": "import Vue from 'vue';\nimport utils from '../services/utils';\n\nexport default (empty, simpleHash = false) => {\n  // Use Date.now() as a simple hash function, which is ok for not-synced types\n  const hashFunc = simpleHash ? Date.now : item => utils.getItemHash(item);\n\n  return {\n    namespaced: true,\n    state: {\n      itemsById: {},\n    },\n    getters: {\n      items: ({ itemsById }) => Object.values(itemsById),\n    },\n    mutations: {\n      setItem(state, value) {\n        const item = Object.assign(empty(value.id), value);\n        if (!item.hash || !simpleHash) {\n          item.hash = hashFunc(item);\n        }\n        Vue.set(state.itemsById, item.id, item);\n      },\n      patchItem(state, patch) {\n        const item = state.itemsById[patch.id];\n        if (item) {\n          Object.assign(item, patch);\n          item.hash = hashFunc(item);\n          Vue.set(state.itemsById, item.id, item);\n          return true;\n        }\n        return false;\n      },\n      deleteItem(state, id) {\n        Vue.delete(state.itemsById, id);\n      },\n    },\n    actions: {},\n  };\n};\n"
  },
  {
    "path": "src/store/notification.js",
    "content": "import providerRegistry from '../services/providers/common/providerRegistry';\nimport utils from '../services/utils';\n\nconst defaultTimeout = 5000; // 5 sec\n\nexport default {\n  namespaced: true,\n  state: {\n    items: [],\n  },\n  mutations: {\n    setItems: (state, value) => {\n      state.items = value;\n    },\n  },\n  actions: {\n    showItem({ state, commit }, item) {\n      const existingItem = utils.someResult(\n        state.items,\n        other => other.type === item.type && other.content === item.content && item,\n      );\n      if (existingItem) {\n        return existingItem.promise;\n      }\n\n      item.promise = new Promise((resolve, reject) => {\n        commit('setItems', [...state.items, item]);\n        const removeItem = () => commit(\n          'setItems',\n          state.items.filter(otherItem => otherItem !== item),\n        );\n        setTimeout(\n          () => removeItem(),\n          item.timeout || defaultTimeout,\n        );\n        item.resolve = (res) => {\n          removeItem();\n          resolve(res);\n        };\n        item.reject = (err) => {\n          removeItem();\n          reject(err);\n        };\n      });\n\n      return item.promise;\n    },\n    info({ dispatch }, content) {\n      return dispatch('showItem', {\n        type: 'info',\n        content,\n      });\n    },\n    badge({ dispatch }, content) {\n      return dispatch('showItem', {\n        type: 'badge',\n        content,\n      });\n    },\n    confirm({ dispatch }, content) {\n      return dispatch('showItem', {\n        type: 'confirm',\n        content,\n        timeout: 10000, // 10 sec\n      });\n    },\n    error({ dispatch, rootState }, error) {\n      const item = { type: 'error' };\n      if (error) {\n        if (error.message) {\n          item.content = error.message;\n        } else if (error.status) {\n          const location = rootState.queue.currentLocation;\n          if (location.providerId) {\n            const provider = providerRegistry.providersById[location.providerId];\n            item.content = `HTTP error ${error.status} on ${provider.name} location.`;\n          } else {\n            item.content = `HTTP error ${error.status}.`;\n          }\n        } else {\n          item.content = `${error}`;\n        }\n      }\n      if (!item.content || item.content === '[object Object]') {\n        item.content = 'Unknown error.';\n      }\n      return dispatch('showItem', item);\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/queue.js",
    "content": "const setter = propertyName => (state, value) => {\n  state[propertyName] = value;\n};\n\nlet queue = Promise.resolve();\n\nexport default {\n  namespaced: true,\n  state: {\n    isEmpty: true,\n    isSyncRequested: false,\n    isPublishRequested: false,\n    currentLocation: {},\n  },\n  mutations: {\n    setIsEmpty: setter('isEmpty'),\n    setIsSyncRequested: setter('isSyncRequested'),\n    setIsPublishRequested: setter('isPublishRequested'),\n    setCurrentLocation: setter('currentLocation'),\n  },\n  actions: {\n    enqueue({ state, commit, dispatch }, cb) {\n      if (state.offline) {\n        // No need to enqueue\n        return;\n      }\n      const checkOffline = () => {\n        if (state.offline) {\n          // Empty queue\n          queue = Promise.resolve();\n          commit('setIsEmpty', true);\n          throw new Error('offline');\n        }\n      };\n      if (state.isEmpty) {\n        commit('setIsEmpty', false);\n      }\n      const newQueue = queue\n        .then(() => checkOffline())\n        .then(() => Promise.resolve()\n          .then(() => cb())\n          .catch((err) => {\n            console.error(err); // eslint-disable-line no-console\n            checkOffline();\n            dispatch('notification/error', err, { root: true });\n          })\n          .then(() => {\n            if (newQueue === queue) {\n              commit('setIsEmpty', true);\n            }\n          }));\n      queue = newQueue;\n    },\n    enqueueSyncRequest({ state, commit, dispatch }, cb) {\n      if (!state.isSyncRequested) {\n        commit('setIsSyncRequested', true);\n        const unset = () => commit('setIsSyncRequested', false);\n        dispatch('enqueue', () => cb().then(unset, (err) => {\n          unset();\n          throw err;\n        }));\n      }\n    },\n    enqueuePublishRequest({ state, commit, dispatch }, cb) {\n      if (!state.isSyncRequested) {\n        commit('setIsPublishRequested', true);\n        const unset = () => commit('setIsPublishRequested', false);\n        dispatch('enqueue', () => cb().then(unset, (err) => {\n          unset();\n          throw err;\n        }));\n      }\n    },\n    async doWithLocation({ commit }, { location, action }) {\n      try {\n        commit('setCurrentLocation', location);\n        return await action();\n      } finally {\n        commit('setCurrentLocation', {});\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/syncedContent.js",
    "content": "import moduleTemplate from './moduleTemplate';\nimport empty from '../data/empties/emptySyncedContent';\n\nconst module = moduleTemplate(empty, true);\n\nmodule.getters = {\n  ...module.getters,\n  current: ({ itemsById }, getters, rootState, rootGetters) =>\n    itemsById[`${rootGetters['file/current'].id}/syncedContent`] || empty(),\n};\n\nexport default module;\n"
  },
  {
    "path": "src/store/userInfo.js",
    "content": "import Vue from 'vue';\n\nexport default {\n  namespaced: true,\n  state: {\n    itemsById: {},\n  },\n  mutations: {\n    setItem: ({ itemsById }, item) => {\n      const itemToSet = {\n        ...item,\n      };\n      const existingItem = itemsById[item.id];\n      if (existingItem) {\n        if (!itemToSet.name) {\n          itemToSet.name = existingItem.name;\n        }\n        if (!itemToSet.imageUrl) {\n          itemToSet.imageUrl = existingItem.imageUrl;\n        }\n      }\n      Vue.set(itemsById, item.id, itemToSet);\n    },\n  },\n};\n"
  },
  {
    "path": "src/store/workspace.js",
    "content": "import utils from '../services/utils';\nimport providerRegistry from '../services/providers/common/providerRegistry';\n\nexport default {\n  namespaced: true,\n  state: {\n    currentWorkspaceId: null,\n    lastFocus: 0,\n  },\n  mutations: {\n    setCurrentWorkspaceId: (state, value) => {\n      state.currentWorkspaceId = value;\n    },\n    setLastFocus: (state, value) => {\n      state.lastFocus = value;\n    },\n  },\n  getters: {\n    workspacesById: (state, getters, rootState, rootGetters) => {\n      const workspacesById = {};\n      const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken'];\n      Object.entries(rootGetters['data/workspaces']).forEach(([id, workspace]) => {\n        const sanitizedWorkspace = {\n          id,\n          providerId: 'googleDriveAppData',\n          sub: mainWorkspaceToken && mainWorkspaceToken.sub,\n          ...workspace,\n        };\n        // Filter workspaces that don't have a provider\n        const workspaceProvider = providerRegistry.providersById[sanitizedWorkspace.providerId];\n        if (workspaceProvider) {\n          // Build the url with the current hostname\n          const params = workspaceProvider.getWorkspaceParams(sanitizedWorkspace);\n          sanitizedWorkspace.url = utils.addQueryParams('app', params, true);\n          sanitizedWorkspace.locationUrl = workspaceProvider\n            .getWorkspaceLocationUrl(sanitizedWorkspace);\n          workspacesById[id] = sanitizedWorkspace;\n        }\n      });\n      return workspacesById;\n    },\n    mainWorkspace: (state, { workspacesById }) => workspacesById.main,\n    currentWorkspace: ({ currentWorkspaceId }, { workspacesById, mainWorkspace }) =>\n      workspacesById[currentWorkspaceId] || mainWorkspace,\n    currentWorkspaceIsGit: (state, { currentWorkspace }) =>\n      currentWorkspace.providerId === 'githubWorkspace'\n      || currentWorkspace.providerId === 'gitlabWorkspace',\n    currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) =>\n      currentWorkspace.providerId === 'githubWorkspace'\n      || currentWorkspace.providerId === 'gitlabWorkspace',\n    lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`,\n    lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`,\n    mainWorkspaceToken: (state, getters, rootState, rootGetters) =>\n      utils.someResult(Object.values(rootGetters['data/googleTokensBySub']), (token) => {\n        if (token.isLogin) {\n          return token;\n        }\n        return null;\n      }),\n    syncToken: (state, { currentWorkspace, mainWorkspaceToken }, rootState, rootGetters) => {\n      switch (currentWorkspace.providerId) {\n        case 'googleDriveWorkspace':\n          return rootGetters['data/googleTokensBySub'][currentWorkspace.sub];\n        case 'githubWorkspace':\n          return rootGetters['data/githubTokensBySub'][currentWorkspace.sub];\n        case 'gitlabWorkspace':\n          return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub];\n        case 'couchdbWorkspace':\n          return rootGetters['data/couchdbTokensBySub'][currentWorkspace.id];\n        default:\n          return mainWorkspaceToken;\n      }\n    },\n    loginType: (state, { currentWorkspace }) => {\n      switch (currentWorkspace.providerId) {\n        case 'googleDriveWorkspace':\n        default:\n          return 'google';\n        case 'githubWorkspace':\n          return 'github';\n        case 'gitlabWorkspace':\n          return 'gitlab';\n      }\n    },\n    loginToken: (state, { loginType, currentWorkspace }, rootState, rootGetters) => {\n      const tokensBySub = rootGetters['data/tokensByType'][loginType];\n      return tokensBySub && tokensBySub[currentWorkspace.sub];\n    },\n    sponsorToken: (state, { mainWorkspaceToken }) => mainWorkspaceToken,\n  },\n  actions: {\n    removeWorkspace: ({ commit, rootGetters }, id) => {\n      const workspaces = {\n        ...rootGetters['data/workspaces'],\n      };\n      delete workspaces[id];\n      commit(\n        'data/setItem',\n        { id: 'workspaces', data: workspaces },\n        { root: true },\n      );\n    },\n    patchWorkspacesById: ({ commit, rootGetters }, workspaces) => {\n      const sanitizedWorkspaces = {};\n      Object\n        .entries({\n          ...rootGetters['data/workspaces'],\n          ...workspaces,\n        })\n        .forEach(([id, workspace]) => {\n          sanitizedWorkspaces[id] = {\n            ...workspace,\n            id,\n            // Do not store urls\n            url: undefined,\n            locationUrl: undefined,\n          };\n        });\n\n      commit(\n        'data/setItem',\n        { id: 'workspaces', data: sanitizedWorkspaces },\n        { root: true },\n      );\n    },\n    setCurrentWorkspaceId: ({ commit, getters }, value) => {\n      commit('setCurrentWorkspaceId', value);\n      const lastFocus = parseInt(localStorage.getItem(getters.lastFocusKey), 10) || 0;\n      commit('setLastFocus', lastFocus);\n    },\n  },\n};\n"
  },
  {
    "path": "src/styles/app.scss",
    "content": "@import './variables.scss';\n\nbody {\n  background-color: #fff;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  position: fixed;\n  tab-size: 4;\n  text-rendering: auto;\n\n  /* Prevent body overscroll on Chrome */\n  overflow: hidden;\n  -webkit-overflow-scrolling: touch;\n}\n\n* {\n  box-sizing: border-box;\n}\n\n::-webkit-scrollbar-track {\n  background-color: transparent;\n}\n\n::-webkit-scrollbar {\n  background-color: transparent;\n\n  /* stylelint-disable-next-line selector-pseudo-class-no-unknown */\n  &:horizontal {\n    height: 8px;\n  }\n\n  /* stylelint-disable-next-line selector-pseudo-class-no-unknown */\n  &:vertical {\n    width: 8px;\n  }\n}\n\n::-webkit-scrollbar-thumb {\n  border-radius: 4px;\n  background-color: #bbb;\n\n  .app--dark & {\n    background-color: #666;\n  }\n}\n\n:focus {\n  outline: none;\n}\n\ninput[type=checkbox] {\n  outline: #349be8 auto 5px;\n}\n\n.icon {\n  width: 100%;\n  height: 100%;\n  display: block;\n\n  * {\n    fill: currentColor;\n  }\n}\n\n.table-wrapper {\n  max-width: 100%;\n  overflow: auto;\n}\n\nbutton,\ninput,\nselect,\ntextarea {\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.text-input {\n  display: block;\n  font-variant-ligatures: no-common-ligatures;\n  width: 100%;\n  height: 36px;\n  padding: 3px 12px;\n  font-size: inherit;\n  line-height: 1.5;\n  color: inherit;\n  background-color: #fff;\n  background-image: none;\n  border: 0;\n  border-radius: $border-radius-base;\n}\n\n.button {\n  color: #333;\n  background-color: transparent;\n  display: inline-block;\n  height: auto;\n  padding: 8px 16px;\n  font-size: 17px;\n  font-weight: 400;\n  line-height: 1.4;\n  text-transform: uppercase;\n  overflow: hidden;\n  text-align: center;\n  white-space: nowrap;\n  vertical-align: middle;\n  -ms-touch-action: manipulation;\n  touch-action: manipulation;\n  cursor: pointer;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n  background-image: none;\n  border: 0;\n  border-radius: $border-radius-base;\n  text-decoration: none;\n\n  &:active,\n  &:focus,\n  &:hover,\n  .hidden-file:focus + & {\n    color: #333;\n    background-color: rgba(0, 0, 0, 0.05);\n    outline: 0;\n    text-decoration: none;\n  }\n\n  .app--dark .layout__panel--editor &,\n  .app--dark .layout__panel--preview & {\n    color: #ccc;\n\n    &:active,\n    &:focus,\n    &:hover {\n      color: #ccc;\n      background-color: rgba(255, 255, 255, 0.067);\n    }\n  }\n\n  &[disabled] {\n    &,\n    &:active,\n    &:focus,\n    &:hover {\n      opacity: 0.33;\n      background-color: transparent;\n      cursor: not-allowed;\n    }\n  }\n}\n\n.button--resolve {\n  background-color: #349be8;\n  color: #fff;\n  margin: -2px 0 -2px 4px;\n  padding: 10px 20px;\n  font-size: 18px;\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: #fff;\n    background-color: darken(#349be8, 8%);\n  }\n}\n\n.textfield {\n  background-color: #fff;\n  border: 0;\n  font-family: inherit;\n  font-weight: 400;\n  font-size: 1.05em;\n  padding: 0 0.6rem;\n  box-sizing: border-box;\n  width: 100%;\n  max-width: 100%;\n  color: inherit;\n  height: 2.4rem;\n\n  &:focus {\n    outline: none;\n  }\n\n  &[disabled] {\n    cursor: not-allowed;\n    background-color: #f0f0f0;\n    color: #999;\n  }\n}\n\n.flex {\n  display: flex;\n}\n\n.flex--row {\n  flex-direction: row;\n}\n\n.flex--column {\n  flex-direction: column;\n}\n\n.flex--center {\n  justify-content: center;\n}\n\n.flex--end {\n  justify-content: flex-end;\n}\n\n.flex--space-between {\n  justify-content: space-between;\n}\n\n.flex--align-center {\n  align-items: center;\n}\n\n.flex--align-end {\n  align-items: flex-end;\n}\n\n.user-name {\n  font-weight: 600;\n}\n\n.side-title {\n  height: 44px;\n  line-height: 36px;\n  padding: 4px 4px 0;\n  background-color: rgba(0, 0, 0, 0.1);\n  flex: none;\n}\n\n.side-title__button {\n  width: 38px;\n  height: 36px;\n  padding: 6px;\n  display: inline-block;\n  background-color: transparent;\n  opacity: 0.75;\n  flex: none;\n\n  /* prevent from seeing wrapped buttons */\n  margin-bottom: 20px;\n\n  &:active,\n  &:focus,\n  &:hover {\n    opacity: 1;\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n}\n\n.side-title__title {\n  text-transform: uppercase;\n  padding: 0 5px;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  width: 100%;\n}\n\n.logo-background {\n  background: no-repeat center url('../assets/logo.svg');\n  background-size: contain;\n}\n\n.gutter {\n  position: absolute;\n  top: 0;\n  height: 100%;\n}\n\n.gutter__background {\n  position: absolute;\n  height: 100%;\n  right: 0;\n}\n\n.new-discussion-button {\n  color: rgba(0, 0, 0, 0.33);\n  position: absolute;\n  left: 0;\n  padding: 3px 3px 3px 0;\n  width: 22px;\n  height: 21px;\n  line-height: 1;\n\n  .app--dark & {\n    color: rgba(255, 255, 255, 0.33);\n  }\n\n  &:active,\n  &:focus,\n  &:hover {\n    color: rgba(0, 0, 0, 0.4);\n\n    .app--dark & {\n      color: rgba(255, 255, 255, 0.4);\n    }\n  }\n}\n\n.discussion-editor-highlighting,\n.discussion-preview-highlighting {\n  background-color: mix($editor-background-light, $selection-highlighting-color, 70%);\n  padding: 0.25em 0;\n\n  .app--dark & {\n    background-color: mix($editor-background-dark, $selection-highlighting-color, 70%);\n  }\n}\n\n.discussion-editor-highlighting--hover,\n.discussion-preview-highlighting--hover {\n  background-color: mix($editor-background-light, $selection-highlighting-color, 50%);\n\n  .app--dark & {\n    background-color: mix($editor-background-dark, $selection-highlighting-color, 50%);\n  }\n\n  * {\n    background-color: transparent;\n  }\n}\n\n.discussion-editor-highlighting--selected,\n.discussion-preview-highlighting--selected {\n  background-color: mix($editor-background-light, $selection-highlighting-color, 20%);\n\n  .app--dark & {\n    background-color: mix($editor-background-dark, $selection-highlighting-color, 20%);\n  }\n\n  * {\n    background-color: transparent;\n  }\n}\n\n.discussion-preview-highlighting {\n  cursor: pointer;\n\n  &.discussion-preview-highlighting--selected {\n    cursor: auto;\n  }\n}\n\n.hidden-rendering-container {\n  position: absolute;\n  width: 500px;\n  left: -1000px;\n}\n\n@media print {\n  body {\n    background-color: transparent !important;\n    color: #000 !important; // Black prints faster\n    overflow: visible !important;\n    position: absolute !important;\n\n    div {\n      display: none !important;\n    }\n\n    a {\n      text-decoration: underline;\n    }\n  }\n\n  body > .app,\n  body > .app > .layout,\n  body > .app > .layout > .layout__panel,\n  body > .app > .layout > .layout__panel > .layout__panel,\n  body > .app > .layout > .layout__panel > .layout__panel > .layout__panel,\n  body > .app > .layout > .layout__panel > .layout__panel > .layout__panel > .layout__panel--preview,\n  body > .app > .layout > .layout__panel > .layout__panel > .layout__panel > .layout__panel--preview div {\n    background-color: transparent !important;\n    display: block !important;\n    height: auto !important;\n    overflow: visible !important;\n    position: static !important;\n    width: auto !important;\n    font-size: 16px;\n  }\n\n  .preview__inner-2 {\n    padding: 0 50px !important;\n  }\n  // scss-lint:enable ImportantRule\n}\n"
  },
  {
    "path": "src/styles/base.scss",
    "content": "@import '../../node_modules/normalize-scss/sass/normalize';\n@import './variables';\n\n@include normalize();\n\nhtml,\nbody {\n  color: $body-color-light;\n  font-size: 16px;\n  font-family: $font-family-main;\n  font-variant-ligatures: common-ligatures;\n  line-height: $line-height-base;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n.app--dark .layout__panel--editor,\n.app--dark .layout__panel--preview {\n  color: $body-color-dark;\n}\n\np,\nblockquote,\npre,\nul,\nol,\ndl {\n  margin: 1.2em 0;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  margin: 1.8em 0;\n  line-height: $line-height-title;\n}\n\nh1,\nh2 {\n  &::after {\n    content: '';\n    display: block;\n    position: relative;\n    top: 0.33em;\n    border-bottom: 1px solid $hr-color;\n  }\n}\n\nol ul,\nul ol,\nul ul,\nol ol {\n  margin: 0;\n}\n\ndt {\n  font-weight: bold;\n}\n\na {\n  color: $link-color;\n  text-decoration: underline;\n  text-decoration-skip: ink;\n\n  &:hover,\n  &:focus {\n    text-decoration: none;\n  }\n}\n\ncode,\npre,\nsamp {\n  font-family: $font-family-monospace;\n  font-size: $font-size-monospace;\n\n  * {\n    font-size: inherit;\n  }\n}\n\nblockquote {\n  color: rgba(0, 0, 0, 0.5);\n  padding-left: 1.5em;\n  border-left: 5px solid rgba(0, 0, 0, 0.1);\n\n  .app--dark .layout__panel--editor &,\n  .app--dark .layout__panel--preview & {\n    color: rgba(255, 255, 255, 0.4);\n    border-left-color: rgba(255, 255, 255, 0.1);\n  }\n}\n\ncode {\n  background-color: $code-bg;\n  border-radius: $border-radius-base;\n  padding: 2px 4px;\n}\n\nhr {\n  border: 0;\n  border-top: 1px solid $hr-color;\n  margin: 2em 0;\n}\n\npre > code {\n  background-color: $code-bg;\n  display: block;\n  padding: 0.5em;\n  -webkit-text-size-adjust: none;\n  overflow-x: auto;\n  white-space: pre;\n}\n\n.toc ul {\n  list-style-type: none;\n  padding-left: 20px;\n}\n\ntable {\n  background-color: transparent;\n  border-collapse: collapse;\n  border-spacing: 0;\n}\n\ntd,\nth {\n  border-right: 1px solid #dcdcdc;\n  padding: 8px 12px;\n\n  &:last-child {\n    border-right: 0;\n  }\n}\n\ntd {\n  border-top: 1px solid #dcdcdc;\n}\n\nmark {\n  background-color: #f8f840;\n}\n\nkbd {\n  font-family: $font-family-main;\n  background-color: #fff;\n  border: 1px solid rgba(63, 63, 63, 0.25);\n  border-radius: 3px;\n  box-shadow: 0 1px 0 rgba(63, 63, 63, 0.25);\n  color: #333;\n  display: inline-block;\n  font-size: 0.8em;\n  margin: 0 0.1em;\n  padding: 0.1em 0.6em;\n  white-space: nowrap;\n}\n\nabbr {\n  &[title] {\n    border-bottom: 1px dotted #777;\n    cursor: help;\n  }\n}\n\nimg {\n  max-width: 100%;\n}\n\n.task-list-item {\n  list-style-type: none;\n}\n\n.task-list-item-checkbox {\n  margin: 0 0.2em 0 -1.3em;\n}\n\n.footnote {\n  font-size: 0.8em;\n  position: relative;\n  top: -0.25em;\n  vertical-align: top;\n}\n\n.page-break-after {\n  page-break-after: always;\n}\n\n.abc-notation-block {\n  overflow-x: auto !important;\n}\n\n.stackedit__html {\n  margin-bottom: 180px;\n  margin-left: auto;\n  margin-right: auto;\n  padding-left: 30px;\n  padding-right: 30px;\n  max-width: 750px;\n}\n\n.stackedit__toc {\n  ul {\n    padding: 0;\n\n    a {\n      margin: 0.5rem 0;\n      padding: 0.5rem 1rem;\n    }\n\n    ul {\n      color: #888;\n      font-size: 0.9em;\n\n      a {\n        margin: 0;\n        padding: 0.1rem 1rem;\n      }\n    }\n  }\n\n  li {\n    display: block;\n  }\n\n  a {\n    display: block;\n    color: inherit;\n    text-decoration: none;\n\n    &:active,\n    &:focus,\n    &:hover {\n      background-color: rgba(0, 0, 0, 0.075);\n      border-radius: $border-radius-base;\n    }\n  }\n}\n\n.stackedit__left {\n  position: fixed;\n  display: none;\n  width: 250px;\n  height: 100%;\n  top: 0;\n  left: 0;\n  overflow-x: hidden;\n  overflow-y: auto;\n  -webkit-overflow-scrolling: touch;\n  -ms-overflow-style: none;\n\n  @media (min-width: 1060px) {\n    display: block;\n  }\n}\n\n.stackedit__right {\n  position: absolute;\n  right: 0;\n  top: 0;\n  left: 0;\n\n  @media (min-width: 1060px) {\n    left: 250px;\n  }\n}\n\n.stackedit--pdf {\n  blockquote {\n    // wkhtmltopdf doesn't like borders with transparency\n    border-left-color: #ececec;\n  }\n\n  // Hide tex annotations in PDF exports\n  annotation,\n  .katex-mathml {\n    display: none;\n  }\n\n  .stackedit__html {\n    padding-left: 0;\n    padding-right: 0;\n    max-width: none;\n  }\n}\n"
  },
  {
    "path": "src/styles/fonts.scss",
    "content": "@font-face {\n  font-family: 'Lato';\n  font-style: normal;\n  font-weight: 400;\n  src: url('../assets/fonts/lato-normal.woff') format('woff');\n}\n\n@font-face {\n  font-family: 'Lato';\n  font-style: italic;\n  font-weight: 400;\n  src: url('../assets/fonts/lato-normal-italic.woff') format('woff');\n}\n\n@font-face {\n  font-family: 'Lato';\n  font-style: normal;\n  font-weight: 600;\n  src: url('../assets/fonts/lato-black.woff') format('woff');\n}\n\n@font-face {\n  font-family: 'Lato';\n  font-style: italic;\n  font-weight: 600;\n  src: url('../assets/fonts/lato-black-italic.woff') format('woff');\n}\n\n@font-face {\n  font-family: 'Roboto Mono';\n  font-style: normal;\n  font-weight: 400;\n  src: url('../assets/fonts/RobotoMono-Regular.woff') format('woff');\n}\n\n@font-face {\n  font-family: 'Roboto Mono';\n  font-style: normal;\n  font-weight: 600;\n  src: url('../assets/fonts/RobotoMono-Bold.woff') format('woff');\n}\n"
  },
  {
    "path": "src/styles/index.js",
    "content": "import 'katex/dist/katex.css';\nimport './fonts.scss';\nimport './prism.scss';\nimport './base.scss';\n"
  },
  {
    "path": "src/styles/markdownHighlighting.scss",
    "content": "@import './variables';\n\n.markdown-highlighting {\n  color: $editor-color-light;\n  caret-color: $editor-color-light-low;\n\n  .app--dark & {\n    color: $editor-color-dark;\n    caret-color: $editor-color-dark-low;\n  }\n\n  font-family: inherit;\n  font-size: inherit;\n  -webkit-font-smoothing: auto;\n  -moz-osx-font-smoothing: auto;\n  font-weight: $editor-font-weight-base;\n\n  .code {\n    font-family: $font-family-monospace;\n    font-size: $font-size-monospace;\n\n    * {\n      font-size: inherit !important;\n    }\n  }\n\n  .pre {\n    color: $editor-color-light;\n\n    .app--dark & {\n      color: $editor-color-dark;\n    }\n\n    font-family: $font-family-monospace;\n    font-size: $font-size-monospace;\n\n    [class*='language-'] {\n      color: $editor-color-light-low;\n\n      .app--dark & {\n        color: $editor-color-dark-low;\n      }\n    }\n\n    * {\n      font-size: inherit !important;\n    }\n\n    &,\n    * {\n      line-height: $line-height-title;\n    }\n  }\n\n  .tag {\n    color: $editor-color-light;\n\n    .app--dark & {\n      color: $editor-color-dark;\n    }\n\n    font-family: $font-family-monospace;\n    font-size: $font-size-monospace;\n    font-weight: $editor-font-weight-bold;\n\n    .punctuation,\n    .attr-value,\n    .attr-name {\n      font-weight: $editor-font-weight-base;\n    }\n\n    * {\n      font-size: inherit !important;\n    }\n  }\n\n  .latex,\n  .math {\n    color: $editor-color-light;\n\n    .app--dark & {\n      color: $editor-color-dark;\n    }\n  }\n\n  .entity {\n    color: $editor-color-light;\n\n    .app--dark & {\n      color: $editor-color-dark;\n    }\n\n    font-family: $font-family-monospace;\n    font-size: $font-size-monospace;\n    font-style: italic;\n\n    * {\n      font-size: inherit !important;\n    }\n  }\n\n  .table {\n    font-family: $font-family-monospace;\n    font-size: $font-size-monospace;\n\n    * {\n      font-size: inherit !important;\n    }\n  }\n\n  .comment {\n    color: $editor-color-light-high;\n\n    .app--dark & {\n      color: $editor-color-dark-high;\n    }\n  }\n\n  .keyword {\n    color: $editor-color-light-low;\n\n    .app--dark & {\n      color: $editor-color-dark-low;\n    }\n\n    font-weight: $editor-font-weight-bold;\n  }\n\n  .code,\n  .img,\n  .img-wrapper,\n  .imgref,\n  .cl-toc {\n    background-color: $code-bg;\n    border-radius: $code-border-radius;\n    padding: 0.15em 0;\n  }\n\n  .img-wrapper {\n    display: inline-block;\n\n    .img {\n      display: inline-block;\n      padding: 0;\n      background-color: transparent;\n    }\n\n    img {\n      max-width: 100%;\n      padding: 0 0.15em;\n      box-sizing: content-box;\n    }\n  }\n\n  .cl-toc {\n    font-size: 2.8em;\n    padding: 0.15em;\n  }\n\n  .blockquote {\n    color: $editor-color-light-blockquote;\n\n    .app--dark & {\n      color: $editor-color-dark-blockquote;\n    }\n  }\n\n  .h1,\n  .h11,\n  .h2,\n  .h22,\n  .h3,\n  .h4,\n  .h5,\n  .h6 {\n    font-weight: $editor-font-weight-bold;\n\n    &,\n    * {\n      line-height: $line-height-title;\n    }\n  }\n\n  .h1,\n  .h11 {\n    font-size: 2em;\n  }\n\n  .h2,\n  .h22 {\n    font-size: 1.5em;\n  }\n\n  .h3 {\n    font-size: 1.17em;\n  }\n\n  .h4 {\n    font-size: 1em;\n  }\n\n  .h5 {\n    font-size: 0.83em;\n  }\n\n  .h6 {\n    font-size: 0.75em;\n  }\n\n  .cl-hash {\n    color: $editor-color-light-high;\n\n    .app--dark & {\n      color: $editor-color-dark-high;\n    }\n  }\n\n  .cl,\n  .hr {\n    color: $editor-color-light-high;\n\n    .app--dark & {\n      color: $editor-color-dark-high;\n    }\n\n    font-style: normal;\n    font-weight: $editor-font-weight-base;\n  }\n\n  .em,\n  .em .cl {\n    font-style: italic;\n  }\n\n  .strong,\n  .strong .cl,\n  .term {\n    font-weight: $editor-font-weight-bold;\n  }\n\n  .cl-del-text {\n    text-decoration: line-through;\n  }\n\n  .cl-mark-text {\n    background-color: #f8f840;\n    color: $editor-color-light-low;\n  }\n\n  .url,\n  .email,\n  .cl-underlined-text {\n    text-decoration: underline;\n  }\n\n  .linkdef .url {\n    color: $editor-color-light-high;\n\n    .app--dark & {\n      color: $editor-color-dark-high;\n    }\n  }\n\n  .fn,\n  .inlinefn,\n  .sup {\n    font-size: smaller;\n    position: relative;\n    top: -0.5em;\n  }\n\n  .sub {\n    bottom: -0.25em;\n    font-size: smaller;\n    position: relative;\n  }\n\n  .img,\n  .imgref,\n  .link,\n  .linkref {\n    color: $editor-color-light-high;\n\n    .app--dark & {\n      color: $editor-color-dark-high;\n    }\n\n    .cl-underlined-text {\n      color: $editor-color-light-low;\n\n      .app--dark & {\n        color: $editor-color-dark-low;\n      }\n    }\n  }\n\n  .cl-title {\n    color: $editor-color-light;\n\n    .app--dark & {\n      color: $editor-color-dark;\n    }\n  }\n}\n\n.markdown-highlighting--inline {\n  .h1,\n  .h11,\n  .h2,\n  .h22,\n  .h3,\n  .h4,\n  .h5,\n  .h6,\n  .cl-toc {\n    font-size: inherit;\n  }\n}\n"
  },
  {
    "path": "src/styles/prism.scss",
    "content": ".token.pre.gfm,\n.prism {\n  * {\n    font-weight: inherit !important;\n  }\n\n  .token.comment,\n  .token.prolog,\n  .token.doctype,\n  .token.cdata {\n    color: #708090;\n  }\n\n  .token.punctuation {\n    color: #999;\n  }\n\n  .namespace {\n    opacity: 0.7;\n  }\n\n  .token.property,\n  .token.tag,\n  .token.boolean,\n  .token.number,\n  .token.constant,\n  .token.symbol,\n  .token.deleted {\n    color: #905;\n  }\n\n  .token.selector,\n  .token.attr-name,\n  .token.string,\n  .token.char,\n  .token.builtin,\n  .token.inserted {\n    color: #690;\n  }\n\n  .token.operator,\n  .token.entity,\n  .token.url,\n  .language-css .token.string,\n  .style .token.string {\n    color: #a67f59;\n  }\n\n  .token.atrule,\n  .token.attr-value,\n  .token.keyword {\n    color: #07a;\n  }\n\n  .token.function {\n    color: #dd4a68;\n  }\n\n  .token.regex,\n  .token.important,\n  .token.variable {\n    color: #e90;\n  }\n\n  .token.important,\n  .token.bold {\n    font-weight: 500;\n  }\n\n  .token.italic {\n    font-style: italic;\n  }\n}\n"
  },
  {
    "path": "src/styles/variables.scss",
    "content": "$font-family-main: Lato, 'Helvetica Neue', Helvetica, sans-serif;\n$font-family-monospace: 'Roboto Mono', 'Lucida Sans Typewriter', 'Lucida Console', monaco, Courrier, monospace;\n$body-color-light: rgba(0, 0, 0, 0.75);\n$body-color-dark: rgba(255, 255, 255, 0.75);\n$code-bg: rgba(0, 0, 0, 0.05);\n$line-height-base: 1.67;\n$line-height-title: 1.33;\n$font-size-monospace: 0.85em;\n$highlighting-color: #ff0;\n$selection-highlighting-color: #ff9632;\n$info-bg: #ffad3326;\n$code-border-radius: 3px;\n$link-color: #0c93e4;\n$error-color: #f31;\n$border-radius-base: 3px;\n$hr-color: rgba(128, 128, 128, 0.33);\n$navbar-bg: #2c2c2c;\n$navbar-color: mix($navbar-bg, #fff, 33%);\n$navbar-hover-color: #fff;\n$navbar-hover-background: rgba(255, 255, 255, 0.1);\n\n$editor-background-light: #fff;\n$editor-background-dark: #1e1e1e;\n\n$editor-color-light: rgba(0, 0, 0, 0.8);\n$editor-color-light-low: #000;\n$editor-color-light-high: rgba(0, 0, 0, 0.28);\n$editor-color-light-blockquote: rgba(0, 0, 0, 0.48);\n\n$editor-color-dark: rgba(255, 255, 255, 0.8);\n$editor-color-dark-low: #fff;\n$editor-color-dark-high: rgba(255, 255, 255, 0.28);\n$editor-color-dark-blockquote: rgba(255, 255, 255, 0.48);\n\n$editor-font-weight-base: 400;\n$editor-font-weight-bold: 600;\n"
  },
  {
    "path": "static/landing/index.html",
    "content": "<!DOCTYPE html>\n<html manifest=\"cache.manifest\">\n\n<head>\n    <title>StackEdit – In-browser Markdown editor</title>\n    <link rel=\"canonical\" href=\"https://stackedit.io/\">\n    <link rel=\"icon\" href=\"static/landing/favicon.ico\" type=\"image/x-icon\">\n    <link rel=\"shortcut icon\" href=\"static/landing/favicon.ico\" type=\"image/x-icon\">\n    <meta charset=\"UTF-8\">\n    <meta name=\"description\"\n          content=\"Full-featured, open-source Markdown editor based on PageDown, the Markdown library used by Stack Overflow and the other Stack Exchange sites.\">\n    <meta name=\"author\" content=\"Benoit Schweblin\">\n    <meta name=\"viewport\" content=\"user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1\">\n    <meta name=\"msvalidate.01\" content=\"5E47EE6F67B069C17E3CDD418351A612\">\n    <meta name=\"google-site-verification\" content=\"iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ\" />\n    <link rel=\"stylesheet\" href=\"https://stackedit.io/style.css\">\n    <style>\n        body {\n            background-color: #fbfbfb;\n        }\n\n        * {\n            box-sizing: border-box;\n        }\n\n        h1 {\n            font-weight: 400;\n            text-align: center;\n            font-size: 2.5em;\n            margin: 2.5em 0;\n        }\n\n        h3 {\n            margin: 1em 0;\n        }\n\n        .button {\n            color: #555;\n            font-size: 20px;\n            background-color: transparent;\n            display: inline-block;\n            height: auto;\n            padding: 6px 12px;\n            margin: 0;\n            font-weight: 400;\n            line-height: 1.4;\n            text-transform: uppercase;\n            overflow: hidden;\n            text-align: center;\n            white-space: nowrap;\n            vertical-align: middle;\n            -ms-touch-action: manipulation;\n            touch-action: manipulation;\n            cursor: pointer;\n            -webkit-user-select: none;\n            -moz-user-select: none;\n            -ms-user-select: none;\n            user-select: none;\n            background-image: none;\n            border: 0;\n            border-radius: 2px;\n            text-decoration: none;\n        }\n\n        .button:active,\n        .button:focus,\n        .button:hover {\n            color: #333;\n            background-color: rgba(0, 0, 0, 0.05);\n            outline: 0;\n            text-decoration: none;\n        }\n\n        .icon {\n            width: 100%;\n            height: 100%;\n            display: inline;\n        }\n\n        .icon * {\n            fill: currentColor;\n        }\n\n        .button .icon {\n            height: 30px;\n            width: 30px;\n            margin: -6px 6px -6px 0;\n        }\n\n        .row {\n            margin: 8em 0;\n        }\n\n        .row::after {\n            display: block;\n            content: '';\n            clear: both;\n        }\n\n        @media (min-width: 700px) {\n            .column {\n                width: 50%;\n                float: right;\n            }\n        }\n\n        .landing {\n            position: absolute;\n            width: 100%;\n            height: 100%;\n        }\n\n        .landing__content {\n            margin-left: auto;\n            margin-right: auto;\n            padding-left: 30px;\n            padding-right: 30px;\n            max-width: 1000px;\n        }\n\n        .landing__footer {\n            padding: 1em 0;\n            text-align: center;\n            background-color: #007acc;\n            color: rgba(255, 255, 255, 0.75);\n            font-size: 0.9em;\n        }\n\n        .landing__footer a {\n            color: #fff;\n        }\n\n        .navigation-bar {\n            background-color: #2c2c2c;\n            position: fixed;\n            padding: 5px;\n            text-align: center;\n            width: 100%;\n            z-index: 1;\n        }\n\n        .navigation-bar__button {\n            color: #b9b9b9;\n        }\n\n        .navigation-bar__button:active,\n        .navigation-bar__button:focus,\n        .navigation-bar__button:hover {\n            color: #fff;\n            background-color: rgba(255, 255, 255, 0.1);\n        }\n\n        .splash-screen {\n            position: relative;\n            width: 100%;\n            height: 100%;\n            padding: 25px;\n        }\n\n        .splash-screen__logo {\n            width: 300px;\n            height: 150px;\n            position: absolute;\n            top: 0;\n            bottom: 0;\n            left: 0;\n            right: 0;\n            margin: auto;\n            background: no-repeat center url('static/landing/logo.svg');\n            background-size: contain;\n        }\n\n        @media (min-width: 700px) {\n            .splash-screen__logo {\n                width: 600px;\n                height: 160px;\n            }\n        }\n\n        .splash-screen__subtitle {\n            position: absolute;\n            text-align: center;\n            color: #777;\n            top: 95px;\n            right: 5px;\n            font-size: 16px;\n        }\n\n        @media (min-width: 700px) {\n            .splash-screen__subtitle {\n                text-align: right;\n                top: 125px;\n                font-size: 22px;\n            }\n        }\n\n        .splash-screen__footer {\n            position: absolute;\n            bottom: 25px;\n            left: 0;\n            width: 100%;\n            text-align: center;\n        }\n\n        .splash-screen__footer .button {\n            padding: 10px 20px;\n        }\n\n        .social {\n            margin: 0 5px;\n        }\n\n        .social a {\n            color: #555;\n            text-decoration: none;\n        }\n\n        .social a:active,\n        .social a:focus,\n        .social a:hover {\n            color: #333;\n            outline: 0;\n        }\n\n        .landing__footer .social a {\n            color: rgba(255, 255, 255, 0.85);\n        }\n\n        .landing__footer .social a:active,\n        .landing__footer .social a:focus,\n        .landing__footer .social a:hover {\n            color: #fff;\n        }\n\n\n        .social .icon {\n            height: 30px;\n            width: 30px;\n        }\n\n        .feature {\n            padding: 5px 5px;\n            border-radius: 2px;\n            max-width: 350px;\n            margin: 1em auto;\n            text-align: center;\n        }\n\n        .image {\n            display: block;\n            margin: 1em auto;\n            border: 1px solid #eee;\n            border-radius: 2px;\n            background-color: #fff;\n        }\n\n        .image img {\n            display: block;\n            margin: 0.5em auto;\n        }\n    </style>\n    <script>\n        function scrollTo(selector) {\n            $('html,body').animate({scrollTop: $(selector).offset().top}, 500);\n        }\n    </script>\n</head>\n\n<body>\n    <div class=\"landing\">\n        <div class=\"navigation-bar\">\n            <a class=\"navigation-bar__button button\" href=\"app\" title=\"The app\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon\"><path d=\"M 16.8363,2.73375C 16.45,2.73375 16.0688,2.88125 15.7712,3.17375L 13.6525,5.2925L 18.955,10.5962L 21.0737,8.47625C 21.665,7.89 21.665,6.94375 21.0737,6.3575L 17.895,3.17375C 17.6025,2.88125 17.2163,2.73375 16.8363,2.73375 Z M 12.9437,6.00125L 4.84375,14.1062L 7.4025,14.39L 7.57875,16.675L 9.85875,16.85L 10.1462,19.4088L 18.2475,11.3038M 4.2475,15.0437L 2.515,21.7337L 9.19875,19.9412L 8.955,17.7838L 6.645,17.6075L 6.465,15.2925\"></path></svg>\n                Start writing\n            </a>\n        </div>\n        <div class=\"splash-screen\">\n            <div class=\"splash-screen__logo\">\n                <div class=\"splash-screen__subtitle\">\n                    In-browser Markdown editor\n\n                    <div class=\"social\">\n                        <a href=\"https://twitter.com/stackedit\" target=\"_blank\">\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon\"><path d=\"M 22.4592,6.01238C 21.6896,6.35373 20.8624,6.58442 19.9944,6.68815C 20.8803,6.15701 21.5609,5.31598 21.8813,4.31378C 21.052,4.80564 20.1336,5.16278 19.156,5.3552C 18.3732,4.52112 17.2579,4 16.0235,4C 13.6534,4 11.7317,5.92147 11.7317,8.29155C 11.7317,8.6279 11.7697,8.95546 11.8429,9.2696C 8.2761,9.0906 5.11376,7.38203 2.9971,4.78551C 2.62766,5.41935 2.41602,6.15656 2.41602,6.94309C 2.41602,8.43204 3.17365,9.74563 4.32524,10.5153C 3.6218,10.4929 2.95997,10.2999 2.3814,9.97846C 2.38099,9.99639 2.38099,10.0143 2.38099,10.0324C 2.38099,12.1118 3.86034,13.8463 5.8236,14.2406C 5.4635,14.3387 5.08435,14.3912 4.69295,14.3912C 4.41641,14.3912 4.14756,14.3642 3.88547,14.3142C 4.43162,16.0191 6.01654,17.26 7.89455,17.2945C 6.42577,18.4457 4.57528,19.1318 2.56454,19.1318C 2.21813,19.1318 1.87652,19.1114 1.54078,19.0717C 3.44004,20.2894 5.69592,21 8.11951,21C 16.0134,21 20.3302,14.4605 20.3302,8.78918C 20.3302,8.60314 20.326,8.41805 20.3177,8.23395C 21.1563,7.62886 21.8839,6.87302 22.4592,6.01238 Z \"/></svg>\n                        </a>\n                        <a href=\"https://github.com/benweet/stackedit\" target=\"_blank\">\n                            <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon\"><path d=\"M 11.9991,2C 6.47774,2 2.00001,6.47712 2.00001,12.0006C 2.00001,16.4184 4.86504,20.1665 8.83877,21.489C 9.33909,21.5807 9.52142,21.272 9.52142,21.007C 9.52142,20.7696 9.51282,20.1407 9.50791,19.3062C 6.72636,19.9105 6.13948,17.9657 6.13948,17.9657C 5.68459,16.8105 5.02895,16.5029 5.02895,16.5029C 4.121,15.8824 5.09771,15.895 5.09771,15.895C 6.10143,15.9657 6.62936,16.9256 6.62936,16.9256C 7.52135,18.4537 8.97014,18.0125 9.53984,17.7565C 9.63069,17.1102 9.88914,16.6696 10.1746,16.4196C 7.95415,16.1672 5.61952,15.3093 5.61952,11.4773C 5.61952,10.3856 6.00934,9.49292 6.64902,8.79388C 6.54588,8.54089 6.20271,7.52417 6.74723,6.14739C 6.74723,6.14739 7.58643,5.87851 9.49686,7.17252C 10.2943,6.95073 11.1501,6.8398 12.0003,6.83594C 12.8499,6.8398 13.7051,6.95073 14.5038,7.17252C 16.413,5.87851 17.2509,6.14739 17.2509,6.14739C 17.7967,7.52417 17.4535,8.54089 17.351,8.79388C 17.9919,9.49292 18.3787,10.3856 18.3787,11.4773C 18.3787,15.3189 16.0403,16.1642 13.8131,16.4118C 14.1717,16.7205 14.4915,17.3308 14.4915,18.2637C 14.4915,19.6005 14.4792,20.6791 14.4792,21.007C 14.4792,21.2744 14.6597,21.5855 15.1668,21.4878C 19.1374,20.1629 22,16.4172 22,12.0006C 22,6.47712 17.5223,2 11.9991,2 Z \"/></svg>\n                        </a>\n                    </div>\n                </div>\n            </div>\n            <div class=\"splash-screen__footer\">\n                <a class=\"button\" href=\"javascript:scrollTo($('.anchor'))\">\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon\"><path d=\"M 11,4L 13,4L 13,16.0104L 18.5052,10.5052L 19.9194,11.9194L 12,19.8388L 4.08058,11.9194L 5.49479,10.5052L 11,16.0104L 11,4 Z \"/></path></svg>\n                    Read more\n                </a>\n            </div>\n        </div>\n        <div class=\"anchor\"></div>\n        <div class=\"landing__content\">\n            <h1>Unrivalled writing experience</h1>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>Rich Markdown editor</h3>\n                        <p>StackEdit’s Markdown syntax highlighting is unique. The refined text formatting of the editor helps you visualize the final rendering of your files.</p>\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <div class=\"image\" style=\"width: 260px\">\n                        <img width=\"230\" src=\"static/landing/syntax-highlighting.gif\">\n                    </div>\n                </div>\n            </div>\n            <div class=\"row\">\n                <img class=\"image\" width=\"410\" src=\"static/landing/navigation-bar.png\">\n                <div class=\"feature\">\n                    <h3>WYSIWYG controls</h3>\n                    <p>StackEdit provides very handy formatting buttons and shortcuts, thanks to PageDown, the WYSIWYG-style Markdown editor used by Stack Overflow.</p>\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>Smart layout</h3>\n                        <p>Whether you write, you review, you comment… StackEdit's layout provides you with the flexibility you need, without sacrifice.</p>\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <img class=\"image\" width=\"360\" src=\"static/landing/smart-layout.png\">\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"feature\">\n                    <h3>Live preview with Scroll Sync</h3>\n                    <p>StackEdit’s Scroll Sync feature accurately binds the scrollbars of the editor panel and the preview panel to ensure that you always keep an eye on the output while writing.</p>\n                </div>\n                <img class=\"image\" width=\"485\" src=\"static/landing/scroll-sync.gif\">\n            </div>\n            <h1>Designed for web writers</h1>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>Stay connected</h3>\n                        <p>StackEdit can sync your files with Google Drive, Dropbox and GitHub. It can also publish them as blog posts to Blogger, WordPress and Zendesk. You can choose whether to upload in Markdown format, HTML, or to format the output using the Handlebars template engine.</p>\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <img class=\"image\" width=\"300\" src=\"static/landing/providers.png\">\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>Collaborate</h3>\n                        <p>With StackEdit, you can share collaborative workspaces, thanks to the synchronization mechanism. If two collaborators are working on the same file at the same time, StackEdit takes care of merging the changes.</p>\n                    </div>\n                    <img class=\"image\" width=\"300\" src=\"static/landing/workspace.png\">\n                </div>\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>Comment</h3>\n                        <p>StackEdit allows you to insert inline comments and embed collaborator discussions in your files, just as well as Microsoft Word and Google Docs.</p>\n                    </div>\n                    <img class=\"image\" width=\"395\" src=\"static/landing/discussion.png\">\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"feature\">\n                    <h3>Write offline!</h3>\n                    <p>Even when you travel, StackEdit is still accessible and lets you write offline just like any desktop application. You have no excuse!</p>\n                </div>\n            </div>\n            <h1>Extended Markdown support</h1>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <br>\n                    <div class=\"image\" style=\"width: 250px\">\n                        <img width=\"230\" src=\"static/landing/gfm.png\">\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>GitHub Flavored Markdown</h3>\n                        <p>StackEdit supports different Markdown flavors such as Markdown Extra, GFM and CommonMark. Each Markdown feature can be enabled or disabled at your convenience.</p>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <br>\n                    <div class=\"image\" style=\"width: 270px\">\n                        <img width=\"250\" src=\"static/landing/katex.gif\">\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>LaTeX mathematical expressions</h3>\n                        <p>StackEdit renders mathematics from LaTeX expressions inside your markdown file, as you would do on Stack Exchange.</p>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <div class=\"image\" style=\"width: 300px\">\n                        <img width=\"280\" src=\"static/landing/mermaid.gif\">\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>UML diagrams</h3>\n                        <p>StackEdit enables you to write sequence diagrams and flow charts using a simple syntax.</p>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"column\">\n                    <div class=\"image\" style=\"width: 280px\">\n                        <img width=\"260\" src=\"static/landing/abc.png\">\n                    </div>\n                </div>\n                <div class=\"column\">\n                    <div class=\"feature\">\n                        <h3>Scores</h3>\n                        <p>StackEdit can render musical scores using the ABC notation.</p>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row\">\n              <div class=\"column\">\n                <div class=\"image\" style=\"width: 250px\">\n                  <img width=\"230\" src=\"static/landing/twemoji.png\">\n                </div>\n              </div>\n              <div class=\"column\">\n                <div class=\"feature\">\n                  <h3>Emojis</h3>\n                  <p>StackEdit supports inserting emojis in your file using the Markdown emoji markup.</p>\n                </div>\n              </div>\n            </div>\n        </div>\n        <div class=\"landing__footer\">\n            <div class=\"social\">\n                <a href=\"https://twitter.com/stackedit\" target=\"_blank\">\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon\"><path d=\"M 22.4592,6.01238C 21.6896,6.35373 20.8624,6.58442 19.9944,6.68815C 20.8803,6.15701 21.5609,5.31598 21.8813,4.31378C 21.052,4.80564 20.1336,5.16278 19.156,5.3552C 18.3732,4.52112 17.2579,4 16.0235,4C 13.6534,4 11.7317,5.92147 11.7317,8.29155C 11.7317,8.6279 11.7697,8.95546 11.8429,9.2696C 8.2761,9.0906 5.11376,7.38203 2.9971,4.78551C 2.62766,5.41935 2.41602,6.15656 2.41602,6.94309C 2.41602,8.43204 3.17365,9.74563 4.32524,10.5153C 3.6218,10.4929 2.95997,10.2999 2.3814,9.97846C 2.38099,9.99639 2.38099,10.0143 2.38099,10.0324C 2.38099,12.1118 3.86034,13.8463 5.8236,14.2406C 5.4635,14.3387 5.08435,14.3912 4.69295,14.3912C 4.41641,14.3912 4.14756,14.3642 3.88547,14.3142C 4.43162,16.0191 6.01654,17.26 7.89455,17.2945C 6.42577,18.4457 4.57528,19.1318 2.56454,19.1318C 2.21813,19.1318 1.87652,19.1114 1.54078,19.0717C 3.44004,20.2894 5.69592,21 8.11951,21C 16.0134,21 20.3302,14.4605 20.3302,8.78918C 20.3302,8.60314 20.326,8.41805 20.3177,8.23395C 21.1563,7.62886 21.8839,6.87302 22.4592,6.01238 Z \"/></svg>\n                </a>\n                <a href=\"https://github.com/benweet/stackedit\" target=\"_blank\">\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" class=\"icon\"><path d=\"M 11.9991,2C 6.47774,2 2.00001,6.47712 2.00001,12.0006C 2.00001,16.4184 4.86504,20.1665 8.83877,21.489C 9.33909,21.5807 9.52142,21.272 9.52142,21.007C 9.52142,20.7696 9.51282,20.1407 9.50791,19.3062C 6.72636,19.9105 6.13948,17.9657 6.13948,17.9657C 5.68459,16.8105 5.02895,16.5029 5.02895,16.5029C 4.121,15.8824 5.09771,15.895 5.09771,15.895C 6.10143,15.9657 6.62936,16.9256 6.62936,16.9256C 7.52135,18.4537 8.97014,18.0125 9.53984,17.7565C 9.63069,17.1102 9.88914,16.6696 10.1746,16.4196C 7.95415,16.1672 5.61952,15.3093 5.61952,11.4773C 5.61952,10.3856 6.00934,9.49292 6.64902,8.79388C 6.54588,8.54089 6.20271,7.52417 6.74723,6.14739C 6.74723,6.14739 7.58643,5.87851 9.49686,7.17252C 10.2943,6.95073 11.1501,6.8398 12.0003,6.83594C 12.8499,6.8398 13.7051,6.95073 14.5038,7.17252C 16.413,5.87851 17.2509,6.14739 17.2509,6.14739C 17.7967,7.52417 17.4535,8.54089 17.351,8.79388C 17.9919,9.49292 18.3787,10.3856 18.3787,11.4773C 18.3787,15.3189 16.0403,16.1642 13.8131,16.4118C 14.1717,16.7205 14.4915,17.3308 14.4915,18.2637C 14.4915,19.6005 14.4792,20.6791 14.4792,21.007C 14.4792,21.2744 14.6597,21.5855 15.1668,21.4878C 19.1374,20.1629 22,16.4172 22,12.0006C 22,6.47712 17.5223,2 11.9991,2 Z \"/></svg>\n                </a>\n            </div>\n            <a href=\"app\" title=\"The app\">The app</a> – <a href=\"https://community.stackedit.io\" target=\"_blank\" title=\"The app\">Community</a><br>\n            Copyright 2013-2019 <a href=\"https://twitter.com/benweet\" target=\"_blank\">Benoit Schweblin</a><br>\n            Licensed under an\n            <a target=\"_blank\" href=\"http://www.apache.org/licenses/LICENSE-2.0\">Apache License</a> –\n            <a href=\"privacy_policy.html\" target=\"_blank\">Privacy Policy</a>\n        </div>\n    </div>\n    <script src=\"https://code.jquery.com/jquery-2.2.4.min.js\" integrity=\"sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=\" crossorigin=\"anonymous\"></script>\n</body>\n\n</html>\n"
  },
  {
    "path": "static/oauth2/callback.html",
    "content": "<!DOCTYPE html>\n<html>\n  <body>\n    <script>\n      var origin = location.protocol + '//' + location.host;\n      (window.opener || window.parent).postMessage(location.hash || location.search, origin);\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "static/sitemap.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n    <url>\n        <loc>https://stackedit.io/</loc>\n        <changefreq>weekly</changefreq>\n        <priority>1.0</priority>\n    </url>\n    <url>\n        <loc>https://stackedit.io/app</loc>\n        <changefreq>weekly</changefreq>\n        <priority>1.0</priority>\n    </url>\n    <url>\n        <loc>https://community.stackedit.io/</loc>\n        <changefreq>weekly</changefreq>\n        <priority>0.8</priority>\n    </url>\n    <url>\n        <loc>https://stackedit.io/privacy_policy.html</loc>\n        <changefreq>monthly</changefreq>\n        <priority>0.6</priority>\n    </url>\n</urlset>\n"
  },
  {
    "path": "test/unit/.eslintrc",
    "content": "{\n  \"env\": {\n    \"jest\": true\n  },\n  \"extends\": [\n    \"../../.eslintrc.js\"\n  ]\n}\n"
  },
  {
    "path": "test/unit/jest.conf.js",
    "content": "const path = require('path');\n\nmodule.exports = {\n  rootDir: path.resolve(__dirname, '../../'),\n  moduleFileExtensions: [\n    'js',\n    'json',\n    'vue',\n  ],\n  moduleNameMapper: {\n    '\\\\.(css|scss)$': 'identity-obj-proxy',\n    '^!raw-loader!': 'identity-obj-proxy',\n    '^worker-loader!\\\\./templateWorker\\\\.js$': '<rootDir>/test/unit/mocks/templateWorkerMock',\n  },\n  transform: {\n    '^.+\\\\.js$': '<rootDir>/node_modules/babel-jest',\n    '.*\\\\.(vue)$': '<rootDir>/node_modules/vue-jest',\n    '.*\\\\.(yml|html|md)$': 'jest-raw-loader',\n  },\n  snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'],\n  setupFiles: [\n    '<rootDir>/test/unit/setup',\n  ],\n  coverageDirectory: '<rootDir>/test/unit/coverage',\n  collectCoverageFrom: [\n    'src/**/*.{js,vue}',\n    '!src/main.js',\n    '!**/node_modules/**',\n  ],\n  globals: {\n    NODE_ENV: 'production',\n  },\n};\n"
  },
  {
    "path": "test/unit/mocks/cryptoMock.js",
    "content": "window.crypto = {\n  getRandomValues(array) {\n    for (let i = 0; i < array.length; i += 1) {\n      array[i] = Math.floor(Math.random() * 1000000);\n    }\n  },\n};\n"
  },
  {
    "path": "test/unit/mocks/localStorageMock.js",
    "content": "const store = {};\nwindow.localStorage = {\n  getItem(key) {\n    return store[key] || null;\n  },\n  setItem(key, value) {\n    store[key] = value.toString();\n  },\n};\n"
  },
  {
    "path": "test/unit/mocks/mutationObserverMock.js",
    "content": "/* eslint-disable class-methods-use-this */\nclass MutationObserver {\n  observe() {\n  }\n}\nwindow.MutationObserver = MutationObserver;\n"
  },
  {
    "path": "test/unit/mocks/templateWorkerMock.js",
    "content": "module.exports = 'test-file-stub';\n"
  },
  {
    "path": "test/unit/setup.js",
    "content": "import Vue from 'vue';\nimport './mocks/cryptoMock';\nimport './mocks/mutationObserverMock';\n\nVue.config.productionTip = false;\n"
  },
  {
    "path": "test/unit/specs/components/ButtonBar.spec.js",
    "content": "import ButtonBar from '../../../../src/components/ButtonBar';\nimport store from '../../../../src/store';\nimport specUtils from '../specUtils';\n\ndescribe('ButtonBar.vue', () => {\n  it('should toggle the navigation bar', async () => specUtils.checkToggler(\n    ButtonBar,\n    wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].showNavigationBar,\n    'toggleNavigationBar',\n  ));\n\n  it('should toggle the side preview', async () => specUtils.checkToggler(\n    ButtonBar,\n    wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].showSidePreview,\n    'toggleSidePreview',\n  ));\n\n  it('should toggle the editor', async () => specUtils.checkToggler(\n    ButtonBar,\n    wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].showEditor,\n    'toggleEditor',\n  ));\n\n  it('should toggle the focus mode', async () => specUtils.checkToggler(\n    ButtonBar,\n    wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].focusMode,\n    'toggleFocusMode',\n  ));\n\n  it('should toggle the scroll sync', async () => specUtils.checkToggler(\n    ButtonBar,\n    wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].scrollSync,\n    'toggleScrollSync',\n  ));\n\n  it('should toggle the status bar', async () => specUtils.checkToggler(\n    ButtonBar,\n    wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].showStatusBar,\n    'toggleStatusBar',\n  ));\n});\n"
  },
  {
    "path": "test/unit/specs/components/ContextMenu.spec.js",
    "content": "import { shallowMount } from '@vue/test-utils';\nimport ContextMenu from '../../../../src/components/ContextMenu';\nimport store from '../../../../src/store';\nimport '../specUtils';\n\nconst mount = () => shallowMount(ContextMenu, { store });\n\ndescribe('ContextMenu.vue', () => {\n  const name = 'Name';\n  const makeOptions = () => ({\n    coordinates: {\n      left: 0,\n      top: 0,\n    },\n    items: [{ name }],\n  });\n\n  it('should open/close itself', async () => {\n    const wrapper = mount();\n    expect(wrapper.contains('.context-menu__item')).toEqual(false);\n    setTimeout(() => wrapper.find('.context-menu__item').trigger('click'), 1);\n    const item = await store.dispatch('contextMenu/open', makeOptions());\n    expect(item.name).toEqual(name);\n  });\n\n  it('should cancel itself', async () => {\n    const wrapper = mount();\n    setTimeout(() => wrapper.trigger('click'), 1);\n    const item = await store.dispatch('contextMenu/open', makeOptions());\n    expect(item).toEqual(null);\n  });\n});\n"
  },
  {
    "path": "test/unit/specs/components/Explorer.spec.js",
    "content": "import { shallowMount } from '@vue/test-utils';\nimport Explorer from '../../../../src/components/Explorer';\nimport store from '../../../../src/store';\nimport workspaceSvc from '../../../../src/services/workspaceSvc';\nimport specUtils from '../specUtils';\n\nconst mount = () => shallowMount(Explorer, { store });\nconst select = (id) => {\n  store.commit('explorer/setSelectedId', id);\n  expect(store.getters['explorer/selectedNode'].item.id).toEqual(id);\n};\nconst ensureExists = file => expect(store.getters.allItemsById).toHaveProperty(file.id);\nconst ensureNotExists = file => expect(store.getters.allItemsById).not.toHaveProperty(file.id);\nconst refreshItem = item => store.getters.allItemsById[item.id];\n\ndescribe('Explorer.vue', () => {\n  it('should create new file in the root folder', async () => {\n    expect(store.state.explorer.newChildNode.isNil).toBeTruthy();\n    const wrapper = mount();\n    wrapper.find('.side-title__button--new-file').trigger('click');\n    expect(store.state.explorer.newChildNode.isNil).toBeFalsy();\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'file',\n      parentId: null,\n    });\n  });\n\n  it('should create new file in a folder', async () => {\n    const folder = await workspaceSvc.storeItem({ type: 'folder' });\n    const wrapper = mount();\n    select(folder.id);\n    wrapper.find('.side-title__button--new-file').trigger('click');\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'file',\n      parentId: folder.id,\n    });\n  });\n\n  it('should not create new files in the trash folder', async () => {\n    const wrapper = mount();\n    select('trash');\n    wrapper.find('.side-title__button--new-file').trigger('click');\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'file',\n      parentId: null,\n    });\n  });\n\n  it('should create new folders in the root folder', async () => {\n    expect(store.state.explorer.newChildNode.isNil).toBeTruthy();\n    const wrapper = mount();\n    wrapper.find('.side-title__button--new-folder').trigger('click');\n    expect(store.state.explorer.newChildNode.isNil).toBeFalsy();\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'folder',\n      parentId: null,\n    });\n  });\n\n  it('should create new folders in a folder', async () => {\n    const folder = await workspaceSvc.storeItem({ type: 'folder' });\n    const wrapper = mount();\n    select(folder.id);\n    wrapper.find('.side-title__button--new-folder').trigger('click');\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'folder',\n      parentId: folder.id,\n    });\n  });\n\n  it('should not create new folders in the trash folder', async () => {\n    const wrapper = mount();\n    select('trash');\n    wrapper.find('.side-title__button--new-folder').trigger('click');\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'folder',\n      parentId: null,\n    });\n  });\n\n  it('should not create new folders in the temp folder', async () => {\n    const wrapper = mount();\n    select('temp');\n    wrapper.find('.side-title__button--new-folder').trigger('click');\n    expect(store.state.explorer.newChildNode.item).toMatchObject({\n      type: 'folder',\n      parentId: null,\n    });\n  });\n\n  it('should move file to the trash folder on delete', async () => {\n    const file = await workspaceSvc.createFile({}, true);\n    expect(file.parentId).toEqual(null);\n    const wrapper = mount();\n    select(file.id);\n    wrapper.find('.side-title__button--delete').trigger('click');\n    ensureExists(file);\n    expect(refreshItem(file).parentId).toEqual('trash');\n    await specUtils.expectBadge('removeFile');\n  });\n\n  it('should not delete the trash folder', async () => {\n    const wrapper = mount();\n    select('trash');\n    wrapper.find('.side-title__button--delete').trigger('click');\n    await specUtils.resolveModal('trashDeletion');\n    await specUtils.expectBadge('removeFile', false);\n  });\n\n  it('should not delete file in the trash folder', async () => {\n    const file = await workspaceSvc.createFile({ parentId: 'trash' }, true);\n    const wrapper = mount();\n    select(file.id);\n    wrapper.find('.side-title__button--delete').trigger('click');\n    await specUtils.resolveModal('trashDeletion');\n    ensureExists(file);\n    await specUtils.expectBadge('removeFile', false);\n  });\n\n  it('should delete the temp folder after confirmation', async () => {\n    const file = await workspaceSvc.createFile({ parentId: 'temp' }, true);\n    const wrapper = mount();\n    select('temp');\n    wrapper.find('.side-title__button--delete').trigger('click');\n    await specUtils.resolveModal('tempFolderDeletion');\n    ensureNotExists(file);\n    await specUtils.expectBadge('removeFolder');\n  });\n\n  it('should delete temp file after confirmation', async () => {\n    const file = await workspaceSvc.createFile({ parentId: 'temp' }, true);\n    const wrapper = mount();\n    select(file.id);\n    wrapper.find('.side-title__button--delete').trigger('click');\n    ensureExists(file);\n    await specUtils.resolveModal('tempFileDeletion');\n    ensureNotExists(file);\n    await specUtils.expectBadge('removeFile');\n  });\n\n  it('should delete folder after confirmation', async () => {\n    const folder = await workspaceSvc.storeItem({ type: 'folder' });\n    const file = await workspaceSvc.createFile({ parentId: folder.id }, true);\n    const wrapper = mount();\n    select(folder.id);\n    wrapper.find('.side-title__button--delete').trigger('click');\n    await specUtils.resolveModal('folderDeletion');\n    ensureNotExists(folder);\n    // Make sure file has been moved to Trash\n    ensureExists(file);\n    expect(refreshItem(file).parentId).toEqual('trash');\n    await specUtils.expectBadge('removeFolder');\n  });\n\n  it('should rename file', async () => {\n    const file = await workspaceSvc.createFile({}, true);\n    const wrapper = mount();\n    select(file.id);\n    wrapper.find('.side-title__button--rename').trigger('click');\n    expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id);\n  });\n\n  it('should rename folder', async () => {\n    const folder = await workspaceSvc.storeItem({ type: 'folder' });\n    const wrapper = mount();\n    select(folder.id);\n    wrapper.find('.side-title__button--rename').trigger('click');\n    expect(store.getters['explorer/editingNode'].item.id).toEqual(folder.id);\n  });\n\n  it('should not rename the trash folder', async () => {\n    const wrapper = mount();\n    select('trash');\n    wrapper.find('.side-title__button--rename').trigger('click');\n    expect(store.getters['explorer/editingNode'].isNil).toBeTruthy();\n  });\n\n  it('should not rename the temp folder', async () => {\n    const wrapper = mount();\n    select('temp');\n    wrapper.find('.side-title__button--rename').trigger('click');\n    expect(store.getters['explorer/editingNode'].isNil).toBeTruthy();\n  });\n\n  it('should close itself', async () => {\n    store.dispatch('data/toggleExplorer', true);\n    specUtils.checkToggler(\n      Explorer,\n      wrapper => wrapper.find('.side-title__button--close').trigger('click'),\n      () => store.getters['data/layoutSettings'].showExplorer,\n      'toggleExplorer',\n    );\n  });\n});\n"
  },
  {
    "path": "test/unit/specs/components/ExplorerNode.spec.js",
    "content": "import { shallowMount } from '@vue/test-utils';\nimport ExplorerNode from '../../../../src/components/ExplorerNode';\nimport store from '../../../../src/store';\nimport workspaceSvc from '../../../../src/services/workspaceSvc';\nimport explorerSvc from '../../../../src/services/explorerSvc';\nimport specUtils from '../specUtils';\n\nconst makeFileNode = async () => {\n  const file = await workspaceSvc.createFile({}, true);\n  const node = store.getters['explorer/nodeMap'][file.id];\n  expect(node.item.id).toEqual(file.id);\n  return node;\n};\n\nconst makeFolderNode = async () => {\n  const folder = await workspaceSvc.storeItem({ type: 'folder' });\n  const node = store.getters['explorer/nodeMap'][folder.id];\n  expect(node.item.id).toEqual(folder.id);\n  return node;\n};\n\nconst mount = node => shallowMount(ExplorerNode, {\n  store,\n  propsData: { node, depth: 1 },\n});\nconst mountAndSelect = (node) => {\n  const wrapper = mount(node);\n  wrapper.find('.explorer-node__item').trigger('click');\n  expect(store.getters['explorer/selectedNode'].item.id).toEqual(node.item.id);\n  expect(wrapper.classes()).toContain('explorer-node--selected');\n  return wrapper;\n};\n\nconst dragAndDrop = (sourceItem, targetItem) => {\n  const sourceNode = store.getters['explorer/nodeMap'][sourceItem.id];\n  mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart', {\n    dataTransfer: { setData: () => {} },\n  });\n  expect(store.state.explorer.dragSourceId).toEqual(sourceItem.id);\n  const targetNode = store.getters['explorer/nodeMap'][targetItem.id];\n  const wrapper = mount(targetNode);\n  wrapper.trigger('dragenter');\n  expect(store.state.explorer.dragTargetId).toEqual(targetItem.id);\n  wrapper.trigger('drop');\n  const expectedParentId = targetItem.type === 'file' ? targetItem.parentId : targetItem.id;\n  expect(store.getters['explorer/selectedNode'].item.parentId).toEqual(expectedParentId);\n};\n\ndescribe('ExplorerNode.vue', () => {\n  const modifiedName = 'Name';\n\n  it('should open file on select after a timeout', async () => {\n    const node = await makeFileNode();\n    mountAndSelect(node);\n    expect(store.getters['file/current'].id).not.toEqual(node.item.id);\n    await new Promise(resolve => setTimeout(resolve, 10));\n    expect(store.getters['file/current'].id).toEqual(node.item.id);\n    await specUtils.expectBadge('switchFile');\n  });\n\n  it('should not open already open file', async () => {\n    const node = await makeFileNode();\n    store.commit('file/setCurrentId', node.item.id);\n    mountAndSelect(node);\n    await new Promise(resolve => setTimeout(resolve, 10));\n    expect(store.getters['file/current'].id).toEqual(node.item.id);\n    await specUtils.expectBadge('switchFile', false);\n  });\n\n  it('should open folder on select after a timeout', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mountAndSelect(node);\n    expect(wrapper.classes()).not.toContain('explorer-node--open');\n    await new Promise(resolve => setTimeout(resolve, 10));\n    expect(wrapper.classes()).toContain('explorer-node--open');\n  });\n\n  it('should open folder on new child', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mountAndSelect(node);\n    // Close the folder\n    wrapper.find('.explorer-node__item').trigger('click');\n    await new Promise(resolve => setTimeout(resolve, 10));\n    expect(wrapper.classes()).not.toContain('explorer-node--open');\n    explorerSvc.newItem();\n    expect(wrapper.classes()).toContain('explorer-node--open');\n  });\n\n  it('should create new file in a folder', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('New file');\n    expect(wrapper.contains('.explorer-node__new-child')).toBe(true);\n    store.commit('explorer/setNewItemName', modifiedName);\n    wrapper.find('.explorer-node__new-child .text-input').trigger('blur');\n    await new Promise(resolve => setTimeout(resolve, 1));\n    expect(store.getters['explorer/selectedNode'].item).toMatchObject({\n      name: modifiedName,\n      type: 'file',\n      parentId: node.item.id,\n    });\n    expect(wrapper.contains('.explorer-node__new-child')).toBe(false);\n    await specUtils.expectBadge('createFile');\n  });\n\n  it('should cancel file creation on escape', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('New file');\n    expect(wrapper.contains('.explorer-node__new-child')).toBe(true);\n    store.commit('explorer/setNewItemName', modifiedName);\n    wrapper.find('.explorer-node__new-child .text-input').trigger('keydown', {\n      keyCode: 27,\n    });\n    await new Promise(resolve => setTimeout(resolve, 1));\n    expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({\n      name: 'modifiedName',\n      type: 'file',\n      parentId: node.item.id,\n    });\n    expect(wrapper.contains('.explorer-node__new-child')).toBe(false);\n    await specUtils.expectBadge('createFile', false);\n  });\n\n  it('should not create new file in a file', async () => {\n    const node = await makeFileNode();\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);\n  });\n\n  it('should not create new file in the trash folder', async () => {\n    const node = store.getters['explorer/nodeMap'].trash;\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);\n  });\n\n  it('should create new folder in folder', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('New folder');\n    expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true);\n    store.commit('explorer/setNewItemName', modifiedName);\n    wrapper.find('.explorer-node__new-child--folder .text-input').trigger('blur');\n    await new Promise(resolve => setTimeout(resolve, 1));\n    expect(store.getters['explorer/selectedNode'].item).toMatchObject({\n      name: modifiedName,\n      type: 'folder',\n      parentId: node.item.id,\n    });\n    expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);\n    await specUtils.expectBadge('createFolder');\n  });\n\n  it('should cancel folder creation on escape', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('New folder');\n    expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true);\n    store.commit('explorer/setNewItemName', modifiedName);\n    wrapper.find('.explorer-node__new-child--folder .text-input').trigger('keydown', {\n      keyCode: 27,\n    });\n    await new Promise(resolve => setTimeout(resolve, 1));\n    expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({\n      name: modifiedName,\n      type: 'folder',\n      parentId: node.item.id,\n    });\n    expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);\n    await specUtils.expectBadge('createFolder', false);\n  });\n\n  it('should not create new folder in a file', async () => {\n    const node = await makeFileNode();\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);\n  });\n\n  it('should not create new folder in the trash folder', async () => {\n    const node = store.getters['explorer/nodeMap'].trash;\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);\n  });\n\n  it('should not create new folder in the temp folder', async () => {\n    const node = store.getters['explorer/nodeMap'].temp;\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);\n  });\n\n  it('should rename file', async () => {\n    const node = await makeFileNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('Rename');\n    expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);\n    wrapper.setData({ editingValue: modifiedName });\n    wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');\n    expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);\n    await specUtils.expectBadge('renameFile');\n  });\n\n  it('should cancel rename file on escape', async () => {\n    const node = await makeFileNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('Rename');\n    expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);\n    wrapper.setData({ editingValue: modifiedName });\n    wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', {\n      keyCode: 27,\n    });\n    expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName);\n    await specUtils.expectBadge('renameFile', false);\n  });\n\n  it('should rename folder', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('Rename');\n    expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);\n    wrapper.setData({ editingValue: modifiedName });\n    wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');\n    expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);\n    await specUtils.expectBadge('renameFolder');\n  });\n\n  it('should cancel rename folder on escape', async () => {\n    const node = await makeFolderNode();\n    const wrapper = mount(node);\n    wrapper.trigger('contextmenu');\n    await specUtils.resolveContextMenu('Rename');\n    expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);\n    wrapper.setData({ editingValue: modifiedName });\n    wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', {\n      keyCode: 27,\n    });\n    expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName);\n    await specUtils.expectBadge('renameFolder', false);\n  });\n\n  it('should not rename the trash folder', async () => {\n    const node = store.getters['explorer/nodeMap'].trash;\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);\n  });\n\n  it('should not rename the temp folder', async () => {\n    const node = store.getters['explorer/nodeMap'].temp;\n    mount(node).trigger('contextmenu');\n    expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);\n  });\n\n  it('should move file into a folder', async () => {\n    const sourceItem = await workspaceSvc.createFile({}, true);\n    const targetItem = await workspaceSvc.storeItem({ type: 'folder' });\n    dragAndDrop(sourceItem, targetItem);\n    await specUtils.expectBadge('moveFile');\n  });\n\n  it('should move folder into a folder', async () => {\n    const sourceItem = await workspaceSvc.storeItem({ type: 'folder' });\n    const targetItem = await workspaceSvc.storeItem({ type: 'folder' });\n    dragAndDrop(sourceItem, targetItem);\n    await specUtils.expectBadge('moveFolder');\n  });\n\n  it('should move file into a file parent folder', async () => {\n    const targetItem = await workspaceSvc.storeItem({ type: 'folder' });\n    const file = await workspaceSvc.createFile({ parentId: targetItem.id }, true);\n    const sourceItem = await workspaceSvc.createFile({}, true);\n    dragAndDrop(sourceItem, file);\n    await specUtils.expectBadge('moveFile');\n  });\n\n  it('should not move the trash folder', async () => {\n    const sourceNode = store.getters['explorer/nodeMap'].trash;\n    mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart');\n    expect(store.state.explorer.dragSourceId).not.toEqual('trash');\n  });\n\n  it('should not move the temp folder', async () => {\n    const sourceNode = store.getters['explorer/nodeMap'].temp;\n    mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart');\n    expect(store.state.explorer.dragSourceId).not.toEqual('temp');\n  });\n\n  it('should not move file to the temp folder', async () => {\n    const targetNode = store.getters['explorer/nodeMap'].temp;\n    const wrapper = mount(targetNode);\n    wrapper.trigger('dragenter');\n    expect(store.state.explorer.dragTargetId).not.toEqual('temp');\n  });\n\n  it('should not move file to a file in the temp folder', async () => {\n    const file = await workspaceSvc.createFile({ parentId: 'temp' }, true);\n    const targetNode = store.getters['explorer/nodeMap'][file.id];\n    const wrapper = mount(targetNode);\n    wrapper.trigger('dragenter');\n    expect(store.state.explorer.dragTargetId).not.toEqual(file.id);\n  });\n});\n"
  },
  {
    "path": "test/unit/specs/components/NavigationBar.spec.js",
    "content": "import NavigationBar from '../../../../src/components/NavigationBar';\nimport store from '../../../../src/store';\nimport specUtils from '../specUtils';\n\ndescribe('NavigationBar.vue', () => {\n  it('should toggle the explorer', async () => specUtils.checkToggler(\n    NavigationBar,\n    wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'),\n    () => store.getters['data/layoutSettings'].showExplorer,\n    'toggleExplorer',\n  ));\n\n  it('should toggle the side bar', async () => specUtils.checkToggler(\n    NavigationBar,\n    wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'),\n    () => store.getters['data/layoutSettings'].showSideBar,\n    'toggleSideBar',\n  ));\n});\n"
  },
  {
    "path": "test/unit/specs/components/Notification.spec.js",
    "content": "import { shallowMount } from '@vue/test-utils';\nimport Notification from '../../../../src/components/Notification';\nimport store from '../../../../src/store';\nimport '../specUtils';\n\nconst mount = () => shallowMount(Notification, { store });\n\ndescribe('Notification.vue', () => {\n  it('should autoclose itself', async () => {\n    const wrapper = mount();\n    expect(wrapper.contains('.notification__item')).toBe(false);\n    store.dispatch('notification/showItem', {\n      type: 'info',\n      content: 'Test',\n      timeout: 10,\n    });\n    expect(wrapper.contains('.notification__item')).toBe(true);\n    await new Promise(resolve => setTimeout(resolve, 10));\n    expect(wrapper.contains('.notification__item')).toBe(false);\n  });\n\n  it('should show messages from top to bottom', async () => {\n    const wrapper = mount();\n    store.dispatch('notification/info', 'Test 1');\n    store.dispatch('notification/info', 'Test 2');\n    const items = wrapper.findAll('.notification__item');\n    expect(items.length).toEqual(2);\n    expect(items.at(0).text()).toMatch(/Test 1/);\n    expect(items.at(1).text()).toMatch(/Test 2/);\n  });\n\n  it('should not open the same message twice', async () => {\n    const wrapper = mount();\n    store.dispatch('notification/info', 'Test');\n    store.dispatch('notification/info', 'Test');\n    expect(wrapper.findAll('.notification__item').length).toEqual(1);\n  });\n});\n"
  },
  {
    "path": "test/unit/specs/specUtils.js",
    "content": "import { shallowMount } from '@vue/test-utils';\nimport store from '../../../src/store';\nimport utils from '../../../src/services/utils';\nimport '../../../src/icons';\nimport '../../../src/components/common/vueGlobals';\n\nconst clone = object => JSON.parse(JSON.stringify(object));\n\nconst deepAssign = (target, origin) => {\n  Object.entries(origin).forEach(([key, value]) => {\n    const type = Object.prototype.toString.call(value);\n    if (type === '[object Object]' && Object.keys(value).length) {\n      deepAssign(target[key], value);\n    } else {\n      target[key] = value;\n    }\n  });\n};\n\nconst freshState = clone(store.state);\n\nbeforeEach(() => {\n  // Restore store state before each test\n  deepAssign(store.state, clone(freshState));\n});\n\nexport default {\n  async checkToggler(Component, toggler, checker, featureId) {\n    const wrapper = shallowMount(Component, { store });\n    const valueBefore = checker();\n    toggler(wrapper);\n    const valueAfter = checker();\n    expect(valueAfter).toEqual(!valueBefore);\n    await this.expectBadge(featureId);\n  },\n  async resolveModal(type) {\n    const config = store.getters['modal/config'];\n    expect(config).toBeTruthy();\n    expect(config.type).toEqual(type);\n    config.resolve();\n    await new Promise(resolve => setTimeout(resolve, 1));\n  },\n  getContextMenuItem(name) {\n    return utils.someResult(store.state.contextMenu.items, item => item.name === name && item);\n  },\n  async resolveContextMenu(name) {\n    const item = this.getContextMenuItem(name);\n    expect(item).toBeTruthy();\n    store.state.contextMenu.resolve(item);\n    await new Promise(resolve => setTimeout(resolve, 1));\n  },\n  async expectBadge(featureId, isEarned = true) {\n    await new Promise(resolve => setTimeout(resolve, 1));\n    expect(store.getters['data/allBadges'].filter(badge => badge.featureId === featureId)[0]).toMatchObject({\n      isEarned,\n    });\n  },\n};\n"
  }
]